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 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 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
141pub trait Reporter: Send {
143 fn on_event(&mut self, event: &Event);
145}
146
147pub fn sink(reporters: Vec<Box<dyn Reporter>>, redactions: Redactions) -> EventSink {
152 let stack = Arc::new(Mutex::new(reporters));
153 EventSink::new(move |event| {
154 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
174type ScenarioKey = (Arc<str>, Arc<str>);
177
178pub 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 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 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
310pub struct RunTotals {
311 pub passed: usize,
313 pub failed: usize,
315 pub skipped: usize,
317 pub steps: usize,
319 pub attempts: u64,
321}
322
323impl RunTotals {
324 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
346pub struct JsonlReporter<W: Write + Send> {
349 out: W,
350}
351
352impl<W: Write + Send> JsonlReporter<W> {
353 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 #[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 #[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 #[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}