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
242#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
246pub struct RunTotals {
247 pub passed: usize,
249 pub failed: usize,
251 pub skipped: usize,
253 pub steps: usize,
255 pub attempts: u64,
257}
258
259impl RunTotals {
260 pub fn observe(&mut self, event: &Event) {
262 match event {
263 Event::StepFinished { attempts, .. } => {
264 self.steps += 1;
265 self.attempts += u64::from(*attempts);
266 }
267 Event::RunFinished {
268 passed,
269 failed,
270 skipped,
271 ..
272 } => {
273 self.passed = *passed;
274 self.failed = *failed;
275 self.skipped = *skipped;
276 }
277 _ => {}
278 }
279 }
280}
281
282pub struct JsonlReporter<W: Write + Send> {
285 out: W,
286}
287
288impl<W: Write + Send> JsonlReporter<W> {
289 pub fn new(out: W) -> Self {
291 Self { out }
292 }
293}
294
295impl<W: Write + Send> Reporter for JsonlReporter<W> {
296 fn on_event(&mut self, event: &Event) {
297 if let Ok(json) = serde_json::to_string(event) {
298 let _ = writeln!(self.out, "{json}");
299 }
300 if matches!(event, Event::RunFinished { .. }) {
301 let _ = self.out.flush();
302 }
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 #![allow(clippy::unwrap_used)]
309
310 use super::*;
311 use crate::step::StepRef;
312
313 fn sample_events() -> Vec<Event> {
314 vec![
315 Event::RunStarted {
316 schema: 1,
317 run_id: Arc::from("run-1"),
318 },
319 Event::ScenarioStarted {
320 scenario: Arc::from("S"),
321 file: Arc::from("f.feature"),
322 },
323 Event::StepFinished {
324 scenario: Arc::from("S"),
325 engine: Arc::from("hurl"),
326 step: StepRef {
327 file: Arc::from("f.feature"),
328 line: 3,
329 text: Arc::from("I log in"),
330 },
331 status: Status::Passed,
332 attempts: 2,
333 duration_ms: 12,
334 captures: vec!["token".to_owned()],
335 detail: None,
336 },
337 Event::ScenarioFinished {
338 scenario: Arc::from("S"),
339 status: Status::Passed,
340 },
341 Event::RunFinished {
342 passed: 1,
343 failed: 0,
344 skipped: 0,
345 cancelled: false,
346 },
347 ]
348 }
349
350 #[test]
351 fn console_buffers_per_scenario_and_prints_on_finish() {
352 let mut out = Vec::new();
353 {
354 let mut console = ConsoleReporter::new(&mut out, Redactions::default());
355 for event in sample_events() {
356 console.on_event(&event);
357 }
358 }
359 let text = String::from_utf8(out).unwrap();
360 assert!(text.contains("Scenario: S (f.feature)"), "{text}");
361 assert!(
362 text.contains("✓ f.feature:3 — I log in (12ms, 2 attempts)"),
363 "{text}"
364 );
365 assert!(
366 text.contains("summary: 1 passed · 0 failed · 0 skipped"),
367 "{text}"
368 );
369 }
370
371 #[test]
372 fn jsonl_is_the_event_stream() {
373 let mut out = Vec::new();
374 {
375 let mut jsonl = JsonlReporter::new(&mut out);
376 for event in sample_events() {
377 jsonl.on_event(&event);
378 }
379 }
380 let text = String::from_utf8(out).unwrap();
381 let parsed: Vec<Event> = text
382 .lines()
383 .map(|line| serde_json::from_str(line).unwrap())
384 .collect();
385 assert_eq!(parsed, sample_events());
386 }
387
388 #[test]
389 fn totals_fold_steps_and_run_counts() {
390 let mut totals = RunTotals::default();
391 for event in sample_events() {
392 totals.observe(&event);
393 }
394 assert_eq!(
395 totals,
396 RunTotals {
397 passed: 1,
398 failed: 0,
399 skipped: 0,
400 steps: 1,
401 attempts: 2,
402 }
403 );
404 }
405
406 mod properties {
407 #![allow(clippy::ignored_unit_patterns)]
408
409 use super::*;
410 use proptest::prelude::*;
411
412 proptest! {
413 #[test]
417 fn redaction_removes_known_values(
418 secret in "[a-zA-Z0-9]{4,24}",
419 prefix in ".{0,30}",
420 suffix in ".{0,30}",
421 ) {
422 let redactions = Redactions::new([secret.clone()]);
423 let rendered = format!("{prefix}{secret}{suffix}");
424 let redacted = redactions.apply(&rendered);
425 prop_assert!(!redacted.contains(&secret));
426 }
427
428 #[test]
432 fn sink_redacts_before_any_reporter(secret in "[a-zA-Z0-9]{6,20}") {
433 #[derive(Clone)]
434 struct Shared(Arc<Mutex<Vec<u8>>>);
435 impl Write for Shared {
436 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
437 if let Ok(mut out) = self.0.lock() {
438 out.extend_from_slice(buf);
439 }
440 Ok(buf.len())
441 }
442 fn flush(&mut self) -> std::io::Result<()> {
443 Ok(())
444 }
445 }
446 let out = Shared(Arc::new(Mutex::new(Vec::new())));
447 let sink = sink(
448 vec![Box::new(JsonlReporter::new(out.clone()))],
449 Redactions::new([secret.clone()]),
450 );
451 sink.emit(&Event::ScenarioStarted {
452 scenario: Arc::from(format!("uses {secret}")),
453 file: Arc::from(format!("{secret}.feature")),
454 });
455 sink.emit(&Event::StepFinished {
456 scenario: Arc::from(format!("uses {secret}")),
457 engine: Arc::from("hurl"),
458 step: crate::step::StepRef {
459 file: Arc::from(format!("{secret}.feature")),
460 line: 1,
461 text: Arc::from(format!("token is {secret}")),
462 },
463 status: Status::Failed,
464 attempts: 1,
465 duration_ms: 1,
466 captures: vec![format!("cap-{secret}")],
467 detail: Some(format!("boom {secret}")),
468 });
469 sink.emit(&Event::RunFinished {
470 passed: 0,
471 failed: 1,
472 skipped: 0,
473 cancelled: false,
474 });
475 let text = String::from_utf8(out.0.lock().unwrap().clone()).unwrap();
476 prop_assert!(!text.is_empty());
477 prop_assert!(!text.contains(&secret), "{text}");
478 }
479
480 #[test]
482 fn console_never_prints_known_secrets(secret in "[a-zA-Z0-9]{6,20}") {
483 let mut out = Vec::new();
484 {
485 let mut console = ConsoleReporter::new(
486 &mut out,
487 Redactions::new([secret.clone()]),
488 );
489 console.on_event(&Event::ScenarioStarted {
490 scenario: Arc::from("S"),
491 file: Arc::from("f"),
492 });
493 console.on_event(&Event::StepFinished {
494 scenario: Arc::from("S"),
495 engine: Arc::from("hurl"),
496 step: StepRef {
497 file: Arc::from("f"),
498 line: 1,
499 text: Arc::from(format!("token is {secret}")),
500 },
501 status: Status::Failed,
502 attempts: 1,
503 duration_ms: 1,
504 captures: Vec::new(),
505 detail: None,
506 });
507 console.on_event(&Event::ScenarioFinished {
508 scenario: Arc::from("S"),
509 status: Status::Failed,
510 });
511 }
512 let text = String::from_utf8(out).unwrap();
513 prop_assert!(!text.contains(&secret), "{text}");
514 }
515 }
516 }
517}