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