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 {
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
134pub trait Reporter: Send {
136 fn on_event(&mut self, event: &Event);
138}
139
140pub fn sink(reporters: Vec<Box<dyn Reporter>>, redactions: Redactions) -> EventSink {
145 let stack = Arc::new(Mutex::new(reporters));
146 EventSink::new(move |event| {
147 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
167type ScenarioKey = (Arc<str>, Arc<str>);
170
171pub 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 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 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
303pub struct RunTotals {
304 pub passed: usize,
306 pub failed: usize,
308 pub skipped: usize,
310 pub steps: usize,
312 pub attempts: u64,
314}
315
316impl RunTotals {
317 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
339pub struct JsonlReporter<W: Write + Send> {
342 out: W,
343}
344
345impl<W: Write + Send> JsonlReporter<W> {
346 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 #[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 #[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 #[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}