1use std::io::Write;
13use std::sync::{Arc, Mutex};
14
15use crate::event::{Event, EventSink};
16use crate::step::Status;
17
18#[derive(Debug, Clone, Default)]
20pub struct Redactions(Vec<String>);
21
22impl Redactions {
23 pub fn new(values: impl IntoIterator<Item = String>) -> Self {
25 Self(values.into_iter().filter(|v| !v.is_empty()).collect())
26 }
27
28 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 pub fn is_empty(&self) -> bool {
39 self.0.is_empty()
40 }
41
42 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 { scenario, file } => Event::ScenarioStarted {
54 scenario: s(scenario),
55 file: s(file),
56 },
57 Event::BatchStarted {
58 scenario,
59 engine,
60 steps,
61 } => Event::BatchStarted {
62 scenario: s(scenario),
63 engine: s(engine),
64 steps: *steps,
65 },
66 Event::EntryRunning {
67 scenario,
68 engine,
69 entry,
70 retry,
71 } => Event::EntryRunning {
72 scenario: s(scenario),
73 engine: s(engine),
74 entry: *entry,
75 retry: *retry,
76 },
77 Event::StepFinished {
78 scenario,
79 engine,
80 step,
81 status,
82 attempts,
83 duration_ms,
84 captures,
85 detail,
86 } => Event::StepFinished {
87 scenario: s(scenario),
88 engine: s(engine),
89 step: crate::step::StepRef {
90 file: s(&step.file),
91 line: step.line,
92 text: s(&step.text),
93 },
94 status: *status,
95 attempts: *attempts,
96 duration_ms: *duration_ms,
97 captures: captures.iter().map(|name| self.apply(name)).collect(),
98 detail: detail.as_deref().map(|text| self.apply(text)),
99 },
100 Event::ScenarioFinished { scenario, status } => Event::ScenarioFinished {
101 scenario: s(scenario),
102 status: *status,
103 },
104 Event::RunFinished { .. } => event.clone(),
105 }
106 }
107}
108
109pub trait Reporter: Send {
111 fn on_event(&mut self, event: &Event);
113}
114
115pub fn sink(reporters: Vec<Box<dyn Reporter>>, redactions: Redactions) -> EventSink {
120 let stack = Arc::new(Mutex::new(reporters));
121 EventSink::new(move |event| {
122 if let Ok(mut stack) = stack.lock() {
123 if redactions.is_empty() {
124 for reporter in stack.iter_mut() {
125 reporter.on_event(event);
126 }
127 } else {
128 let redacted = redactions.apply_event(event);
129 for reporter in stack.iter_mut() {
130 reporter.on_event(&redacted);
131 }
132 }
133 }
134 })
135}
136
137pub struct ConsoleReporter<W: Write + Send> {
139 out: W,
140 redactions: Redactions,
141 buffers: Vec<(Arc<str>, Vec<String>)>,
142}
143
144impl<W: Write + Send> ConsoleReporter<W> {
145 pub fn new(out: W, redactions: Redactions) -> Self {
147 Self {
148 out,
149 redactions,
150 buffers: Vec::new(),
151 }
152 }
153
154 fn buffer_for(&mut self, scenario: &Arc<str>) -> &mut Vec<String> {
155 if let Some(position) = self.buffers.iter().position(|(name, _)| name == scenario) {
156 &mut self.buffers[position].1
157 } else {
158 self.buffers.push((Arc::clone(scenario), Vec::new()));
159 &mut self
160 .buffers
161 .last_mut()
162 .unwrap_or_else(|| unreachable!("buffer just pushed"))
163 .1
164 }
165 }
166}
167
168fn glyph(status: Status) -> &'static str {
169 match status {
170 Status::Passed => "✓",
171 Status::Failed => "✗",
172 Status::Skipped => "∅",
173 Status::Warned => "⚠",
174 }
175}
176
177impl<W: Write + Send> Reporter for ConsoleReporter<W> {
178 fn on_event(&mut self, event: &Event) {
179 match event {
180 Event::RunStarted { run_id, .. } => {
181 let _ = writeln!(self.out, "proef run {run_id}");
182 }
183 Event::ScenarioStarted { scenario, file } => {
184 let header = format!("\n Scenario: {scenario} ({file})");
185 self.buffer_for(scenario).push(header);
186 }
187 Event::BatchStarted { .. } | Event::EntryRunning { .. } => {}
190 Event::StepFinished {
191 scenario,
192 step,
193 status,
194 attempts,
195 duration_ms,
196 ..
197 } => {
198 let attempts_note = if *attempts > 1 {
199 format!(", {attempts} attempts")
200 } else {
201 String::new()
202 };
203 let line = format!(
204 " {} {}:{} — {} ({duration_ms}ms{attempts_note})",
205 glyph(*status),
206 step.file,
207 step.line,
208 step.text
209 );
210 let line = self.redactions.apply(&line);
211 self.buffer_for(scenario).push(line);
212 }
213 Event::ScenarioFinished { scenario, status } => {
214 let lines = self
215 .buffers
216 .iter()
217 .position(|(name, _)| name == scenario)
218 .map(|position| self.buffers.remove(position).1)
219 .unwrap_or_default();
220 for line in lines {
221 let _ = writeln!(self.out, "{line}");
222 }
223 let _ = writeln!(self.out, " {} scenario {scenario}", glyph(*status));
224 }
225 Event::RunFinished {
226 passed,
227 failed,
228 skipped,
229 cancelled,
230 } => {
231 let note = if *cancelled { " · cancelled" } else { "" };
232 let _ = writeln!(
233 self.out,
234 "\nsummary: {passed} passed · {failed} failed · {skipped} skipped{note}"
235 );
236 let _ = self.out.flush();
237 }
238 }
239 }
240}
241
242pub struct NormalizeReporter {
247 inner: Box<dyn Reporter>,
248 buffers: Vec<(Arc<str>, Vec<Event>)>,
249}
250
251impl NormalizeReporter {
252 pub fn new(inner: Box<dyn Reporter>) -> Self {
254 Self {
255 inner,
256 buffers: Vec::new(),
257 }
258 }
259
260 fn buffer(&mut self, scenario: &Arc<str>, event: Event) {
261 if let Some((_, events)) = self.buffers.iter_mut().find(|(name, _)| name == scenario) {
262 events.push(event);
263 } else {
264 self.buffers.push((Arc::clone(scenario), vec![event]));
265 }
266 }
267}
268
269impl Reporter for NormalizeReporter {
270 fn on_event(&mut self, event: &Event) {
271 match event {
272 Event::RunStarted { .. } | Event::RunFinished { .. } => {
273 self.inner.on_event(event);
274 }
275 Event::ScenarioStarted { scenario, .. }
276 | Event::BatchStarted { scenario, .. }
277 | Event::EntryRunning { scenario, .. }
278 | Event::StepFinished { scenario, .. } => {
279 let scenario = Arc::clone(scenario);
280 self.buffer(&scenario, event.clone());
281 }
282 Event::ScenarioFinished { scenario, .. } => {
283 let buffered = self
284 .buffers
285 .iter()
286 .position(|(name, _)| name == scenario)
287 .map(|position| self.buffers.remove(position).1)
288 .unwrap_or_default();
289 for buffered_event in &buffered {
290 self.inner.on_event(buffered_event);
291 }
292 self.inner.on_event(event);
293 }
294 }
295 }
296}
297
298#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
302pub struct RunTotals {
303 pub passed: usize,
305 pub failed: usize,
307 pub skipped: usize,
309 pub steps: usize,
311 pub attempts: u64,
313}
314
315impl RunTotals {
316 pub fn observe(&mut self, event: &Event) {
318 match event {
319 Event::StepFinished { attempts, .. } => {
320 self.steps += 1;
321 self.attempts += u64::from(*attempts);
322 }
323 Event::RunFinished {
324 passed,
325 failed,
326 skipped,
327 ..
328 } => {
329 self.passed = *passed;
330 self.failed = *failed;
331 self.skipped = *skipped;
332 }
333 _ => {}
334 }
335 }
336}
337
338pub struct JsonlReporter<W: Write + Send> {
341 out: W,
342}
343
344impl<W: Write + Send> JsonlReporter<W> {
345 pub fn new(out: W) -> Self {
347 Self { out }
348 }
349}
350
351impl<W: Write + Send> Reporter for JsonlReporter<W> {
352 fn on_event(&mut self, event: &Event) {
353 if let Ok(json) = serde_json::to_string(event) {
354 let _ = writeln!(self.out, "{json}");
355 }
356 if matches!(event, Event::RunFinished { .. }) {
357 let _ = self.out.flush();
358 }
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 #![allow(clippy::unwrap_used)]
365
366 use super::*;
367 use crate::step::StepRef;
368
369 fn sample_events() -> Vec<Event> {
370 vec![
371 Event::RunStarted {
372 schema: 1,
373 run_id: Arc::from("run-1"),
374 },
375 Event::ScenarioStarted {
376 scenario: Arc::from("S"),
377 file: Arc::from("f.feature"),
378 },
379 Event::StepFinished {
380 scenario: Arc::from("S"),
381 engine: Arc::from("hurl"),
382 step: StepRef {
383 file: Arc::from("f.feature"),
384 line: 3,
385 text: Arc::from("I log in"),
386 },
387 status: Status::Passed,
388 attempts: 2,
389 duration_ms: 12,
390 captures: vec!["token".to_owned()],
391 detail: None,
392 },
393 Event::ScenarioFinished {
394 scenario: Arc::from("S"),
395 status: Status::Passed,
396 },
397 Event::RunFinished {
398 passed: 1,
399 failed: 0,
400 skipped: 0,
401 cancelled: false,
402 },
403 ]
404 }
405
406 #[test]
407 fn console_buffers_per_scenario_and_prints_on_finish() {
408 let mut out = Vec::new();
409 {
410 let mut console = ConsoleReporter::new(&mut out, Redactions::default());
411 for event in sample_events() {
412 console.on_event(&event);
413 }
414 }
415 let text = String::from_utf8(out).unwrap();
416 assert!(text.contains("Scenario: S (f.feature)"), "{text}");
417 assert!(
418 text.contains("✓ f.feature:3 — I log in (12ms, 2 attempts)"),
419 "{text}"
420 );
421 assert!(
422 text.contains("summary: 1 passed · 0 failed · 0 skipped"),
423 "{text}"
424 );
425 }
426
427 #[test]
428 fn jsonl_is_the_event_stream() {
429 let mut out = Vec::new();
430 {
431 let mut jsonl = JsonlReporter::new(&mut out);
432 for event in sample_events() {
433 jsonl.on_event(&event);
434 }
435 }
436 let text = String::from_utf8(out).unwrap();
437 let parsed: Vec<Event> = text
438 .lines()
439 .map(|line| serde_json::from_str(line).unwrap())
440 .collect();
441 assert_eq!(parsed, sample_events());
442 }
443
444 #[test]
445 fn normalize_repairs_interleaving_in_completion_order() {
446 struct Capture(std::sync::Arc<Mutex<Vec<Event>>>);
447 impl Reporter for Capture {
448 fn on_event(&mut self, event: &Event) {
449 if let Ok(mut events) = self.0.lock() {
450 events.push(event.clone());
451 }
452 }
453 }
454 let captured = std::sync::Arc::new(Mutex::new(Vec::new()));
455 let mut normalize =
456 NormalizeReporter::new(Box::new(Capture(std::sync::Arc::clone(&captured))));
457
458 let started = |name: &str| Event::ScenarioStarted {
459 scenario: Arc::from(name),
460 file: Arc::from("f"),
461 };
462 let finished = |name: &str| Event::ScenarioFinished {
463 scenario: Arc::from(name),
464 status: Status::Passed,
465 };
466 normalize.on_event(&started("a"));
468 normalize.on_event(&started("b"));
469 normalize.on_event(&finished("b"));
470 normalize.on_event(&finished("a"));
471
472 let events = captured.lock().unwrap().clone();
473 let names: Vec<String> = events
474 .iter()
475 .map(|e| match e {
476 Event::ScenarioStarted { scenario, .. } => format!("start-{scenario}"),
477 Event::ScenarioFinished { scenario, .. } => format!("finish-{scenario}"),
478 _ => "other".to_owned(),
479 })
480 .collect();
481 assert_eq!(names, ["start-b", "finish-b", "start-a", "finish-a"]);
482 }
483
484 #[test]
485 fn totals_fold_steps_and_run_counts() {
486 let mut totals = RunTotals::default();
487 for event in sample_events() {
488 totals.observe(&event);
489 }
490 assert_eq!(
491 totals,
492 RunTotals {
493 passed: 1,
494 failed: 0,
495 skipped: 0,
496 steps: 1,
497 attempts: 2,
498 }
499 );
500 }
501
502 mod properties {
503 #![allow(clippy::ignored_unit_patterns)]
504
505 use super::*;
506 use proptest::prelude::*;
507
508 proptest! {
509 #[test]
513 fn redaction_removes_known_values(
514 secret in "[a-zA-Z0-9]{4,24}",
515 prefix in ".{0,30}",
516 suffix in ".{0,30}",
517 ) {
518 let redactions = Redactions::new([secret.clone()]);
519 let rendered = format!("{prefix}{secret}{suffix}");
520 let redacted = redactions.apply(&rendered);
521 prop_assert!(!redacted.contains(&secret));
522 }
523
524 #[test]
528 fn sink_redacts_before_any_reporter(secret in "[a-zA-Z0-9]{6,20}") {
529 #[derive(Clone)]
530 struct Shared(Arc<Mutex<Vec<u8>>>);
531 impl Write for Shared {
532 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
533 if let Ok(mut out) = self.0.lock() {
534 out.extend_from_slice(buf);
535 }
536 Ok(buf.len())
537 }
538 fn flush(&mut self) -> std::io::Result<()> {
539 Ok(())
540 }
541 }
542 let out = Shared(Arc::new(Mutex::new(Vec::new())));
543 let sink = sink(
544 vec![Box::new(JsonlReporter::new(out.clone()))],
545 Redactions::new([secret.clone()]),
546 );
547 sink.emit(&Event::ScenarioStarted {
548 scenario: Arc::from(format!("uses {secret}")),
549 file: Arc::from(format!("{secret}.feature")),
550 });
551 sink.emit(&Event::StepFinished {
552 scenario: Arc::from(format!("uses {secret}")),
553 engine: Arc::from("hurl"),
554 step: crate::step::StepRef {
555 file: Arc::from(format!("{secret}.feature")),
556 line: 1,
557 text: Arc::from(format!("token is {secret}")),
558 },
559 status: Status::Failed,
560 attempts: 1,
561 duration_ms: 1,
562 captures: vec![format!("cap-{secret}")],
563 detail: Some(format!("boom {secret}")),
564 });
565 sink.emit(&Event::RunFinished {
566 passed: 0,
567 failed: 1,
568 skipped: 0,
569 cancelled: false,
570 });
571 let text = String::from_utf8(out.0.lock().unwrap().clone()).unwrap();
572 prop_assert!(!text.is_empty());
573 prop_assert!(!text.contains(&secret), "{text}");
574 }
575
576 #[test]
578 fn console_never_prints_known_secrets(secret in "[a-zA-Z0-9]{6,20}") {
579 let mut out = Vec::new();
580 {
581 let mut console = ConsoleReporter::new(
582 &mut out,
583 Redactions::new([secret.clone()]),
584 );
585 console.on_event(&Event::ScenarioStarted {
586 scenario: Arc::from("S"),
587 file: Arc::from("f"),
588 });
589 console.on_event(&Event::StepFinished {
590 scenario: Arc::from("S"),
591 engine: Arc::from("hurl"),
592 step: StepRef {
593 file: Arc::from("f"),
594 line: 1,
595 text: Arc::from(format!("token is {secret}")),
596 },
597 status: Status::Failed,
598 attempts: 1,
599 duration_ms: 1,
600 captures: Vec::new(),
601 detail: None,
602 });
603 console.on_event(&Event::ScenarioFinished {
604 scenario: Arc::from("S"),
605 status: Status::Failed,
606 });
607 }
608 let text = String::from_utf8(out).unwrap();
609 prop_assert!(!text.contains(&secret), "{text}");
610 }
611 }
612 }
613}