Skip to main content

ferridriver_test/reporter/
mod.rs

1//! Reporter system: event-driven, multiplexed, trait-based.
2
3pub mod allure;
4pub mod bdd;
5pub mod blob;
6pub mod dot;
7pub mod empty;
8pub mod github;
9pub mod html;
10pub mod json;
11pub mod junit;
12pub mod progress;
13pub mod rerun;
14pub mod terminal;
15
16use std::sync::Arc;
17use std::time::Duration;
18
19use tokio::sync::mpsc;
20
21use crate::model::{StepCategory, TestId, TestOutcome};
22
23// ── Events ──
24
25#[derive(Debug, Clone)]
26pub struct StepStartedEvent {
27  pub test_id: TestId,
28  pub step_id: String,
29  pub parent_step_id: Option<String>,
30  pub title: String,
31  pub category: StepCategory,
32}
33
34#[derive(Debug, Clone)]
35pub struct StepFinishedEvent {
36  pub test_id: TestId,
37  pub step_id: String,
38  pub title: String,
39  pub category: StepCategory,
40  pub duration: Duration,
41  pub error: Option<String>,
42  /// Arbitrary metadata attached to this step (e.g. BDD keyword/text).
43  pub metadata: Option<serde_json::Value>,
44}
45
46/// Events emitted during a test run.
47#[derive(Debug, Clone)]
48pub enum ReporterEvent {
49  /// The entire run is starting.
50  RunStarted {
51    total_tests: usize,
52    num_workers: u32,
53    /// Arbitrary metadata from config (Playwright's `metadata` field).
54    metadata: serde_json::Value,
55  },
56  /// A worker has been spawned.
57  WorkerStarted { worker_id: u32 },
58  /// A test is about to execute.
59  TestStarted { test_id: TestId, attempt: u32 },
60  /// A step within a test has started (real-time, emitted during execution).
61  StepStarted(Box<StepStartedEvent>),
62  /// A step within a test has finished (real-time, emitted during execution).
63  StepFinished(Box<StepFinishedEvent>),
64  /// A test finished (pass, fail, skip, etc.).
65  TestFinished { test_id: TestId, outcome: TestOutcome },
66  /// A worker has shut down.
67  WorkerFinished { worker_id: u32 },
68  /// The entire run completed.
69  RunFinished {
70    total: usize,
71    passed: usize,
72    failed: usize,
73    skipped: usize,
74    flaky: usize,
75    duration: Duration,
76  },
77}
78
79// ── Reporter Trait ──
80
81/// Trait that all reporters implement.
82#[async_trait::async_trait]
83pub trait Reporter: Send + Sync {
84  /// Called for every event.
85  async fn on_event(&mut self, event: &ReporterEvent);
86
87  /// Called after the run to finalize output (write files, close streams).
88  async fn finalize(&mut self) -> ferridriver::error::Result<()> {
89    Ok(())
90  }
91}
92
93// ── Reporter Set (multiplexer) ──
94
95/// Multiplexes events to multiple reporters.
96pub struct ReporterSet {
97  reporters: Vec<Box<dyn Reporter>>,
98}
99
100impl Default for ReporterSet {
101  fn default() -> Self {
102    Self { reporters: Vec::new() }
103  }
104}
105
106impl ReporterSet {
107  pub fn new(reporters: Vec<Box<dyn Reporter>>) -> Self {
108    Self { reporters }
109  }
110
111  pub fn is_empty(&self) -> bool {
112    self.reporters.is_empty()
113  }
114
115  /// Append an additional reporter (e.g., NAPI ResultCollector).
116  pub fn add(&mut self, reporter: Box<dyn Reporter>) {
117    self.reporters.push(reporter);
118  }
119
120  /// Replace all reporters with a new set.
121  pub fn replace(&mut self, reporters: Vec<Box<dyn Reporter>>) {
122    self.reporters = reporters;
123  }
124
125  pub async fn emit(&mut self, event: &ReporterEvent) {
126    for reporter in &mut self.reporters {
127      reporter.on_event(event).await;
128    }
129  }
130
131  pub async fn finalize(&mut self) {
132    for reporter in &mut self.reporters {
133      if let Err(e) = reporter.finalize().await {
134        tracing::error!("reporter finalize error: {e}");
135      }
136    }
137  }
138}
139
140// ── Event Bus ──
141
142/// Builder for constructing an `EventBus` with registered subscribers.
143///
144/// Register all subscribers before calling `build()`. Once built, the bus
145/// is immutable — no new subscribers can be added. This ensures workers
146/// (which clone the bus) fan out to a fixed set of consumers.
147pub struct EventBusBuilder {
148  subscribers: Vec<mpsc::UnboundedSender<ReporterEvent>>,
149}
150
151impl Default for EventBusBuilder {
152  fn default() -> Self {
153    Self::new()
154  }
155}
156
157impl EventBusBuilder {
158  pub fn new() -> Self {
159    Self {
160      subscribers: Vec::new(),
161    }
162  }
163
164  /// Register a subscriber. Returns a `Subscription` (the receiving end).
165  /// Must be called before `build()`.
166  pub fn subscribe(&mut self) -> Subscription {
167    let (tx, rx) = mpsc::unbounded_channel();
168    self.subscribers.push(tx);
169    Subscription { rx }
170  }
171
172  /// Finalize the bus. No more subscribers can be added after this.
173  pub fn build(self) -> EventBus {
174    let has_subscribers = !self.subscribers.is_empty();
175    EventBus {
176      inner: Arc::new(EventBusInner {
177        has_subscribers,
178        subscribers: std::sync::RwLock::new(self.subscribers),
179      }),
180    }
181  }
182}
183
184/// The receiving end of a subscriber channel.
185pub struct Subscription {
186  pub rx: mpsc::UnboundedReceiver<ReporterEvent>,
187}
188
189/// Fan-out event bus. Workers clone this and call `emit()` — events are
190/// delivered to all subscribers registered at build time.
191///
192/// Clone is cheap (Arc internals). All clones share the same subscriber list.
193#[derive(Clone)]
194pub struct EventBus {
195  inner: Arc<EventBusInner>,
196}
197
198struct EventBusInner {
199  has_subscribers: bool,
200  /// Subscriber channels — frozen after build. Read-only during emit (no lock needed).
201  /// `close()` swaps to empty Vec via `std::sync::RwLock` (write only on shutdown).
202  subscribers: std::sync::RwLock<Vec<mpsc::UnboundedSender<ReporterEvent>>>,
203}
204
205impl EventBus {
206  pub fn has_subscribers(&self) -> bool {
207    self.inner.has_subscribers
208  }
209
210  /// Emit an event to all subscribers. Lock-free read path — `RwLock::read()` never
211  /// blocks other readers. Only `close()` takes a write lock (once, at shutdown).
212  pub fn emit(&self, event: ReporterEvent) {
213    if !self.inner.has_subscribers {
214      return;
215    }
216    let subs = self.inner.subscribers.read().expect("EventBus RwLock poisoned");
217    if subs.is_empty() {
218      return;
219    }
220    let last = subs.len() - 1;
221    for sub in &subs[..last] {
222      let _ = sub.send(event.clone());
223    }
224    let _ = subs[last].send(event);
225  }
226
227  /// Explicitly close all sender channels.
228  pub fn close(&self) {
229    self
230      .inner
231      .subscribers
232      .write()
233      .expect("EventBus RwLock poisoned")
234      .clear();
235  }
236}
237
238// ── Reporter Driver ──
239
240/// Standalone consumer that drains a `Subscription` and drives a `ReporterSet`.
241/// Decoupled from test execution — can run as an independent tokio task.
242///
243/// Spawn this with `tokio::spawn(driver.run())`. When the event bus is dropped
244/// (all senders gone), the subscription channel closes, the driver finalizes
245/// all reporters, and returns the `ReporterSet` for potential reuse.
246pub struct ReporterDriver {
247  reporters: ReporterSet,
248  subscription: Subscription,
249}
250
251impl ReporterDriver {
252  pub fn new(reporters: ReporterSet, subscription: Subscription) -> Self {
253    Self {
254      reporters,
255      subscription,
256    }
257  }
258
259  /// Consume events until the channel closes, finalize reporters, return them.
260  pub async fn run(mut self) -> ReporterSet {
261    while let Some(event) = self.subscription.rx.recv().await {
262      self.reporters.emit(&event).await;
263    }
264    self.reporters.finalize().await;
265    self.reporters
266  }
267}
268
269// ── Factory ──
270
271/// Unified reporter factory. Creates reporters from config names, routing
272/// mode-dependent reporters (terminal, json, junit) based on `mode`.
273pub fn create_reporters_pub(
274  names: &[crate::config::ReporterConfig],
275  output_dir: &std::path::Path,
276  has_bdd: bool,
277  quiet: bool,
278  report_slow_tests: Option<crate::config::ReportSlowTestsConfig>,
279) -> ReporterSet {
280  create_reporters(names, output_dir, has_bdd, quiet, report_slow_tests)
281}
282
283pub(crate) fn create_reporters(
284  names: &[crate::config::ReporterConfig],
285  output_dir: &std::path::Path,
286  _has_bdd: bool,
287  quiet: bool,
288  report_slow_tests: Option<crate::config::ReportSlowTestsConfig>,
289) -> ReporterSet {
290  if names.len() == 1 && matches!(names[0].name.as_str(), "none" | "null" | "empty") {
291    return ReporterSet::default();
292  }
293
294  let mut reporters: Vec<Box<dyn Reporter>> = Vec::new();
295  let mut has_terminal = false;
296
297  for config in names {
298    match config.name.as_str() {
299      // Terminal reporter handles both E2E and BDD — detects BDD by step metadata.
300      "terminal" | "list" | "bdd" | "default" | "" => {
301        if !has_terminal && !quiet {
302          reporters.push(Box::new(
303            terminal::TerminalReporter::new().with_slow_tests_config(report_slow_tests.clone()),
304          ));
305          has_terminal = true;
306        }
307      },
308      "json" => {
309        reporters.push(Box::new(json::JsonReporter::new(output_dir.join("results.json"))));
310      },
311      "junit" => {
312        reporters.push(Box::new(junit::JUnitReporter::new(output_dir.join("junit.xml"))));
313      },
314      "dot" => {
315        reporters.push(Box::new(dot::DotReporter::new()));
316      },
317      "null" | "empty" => {
318        reporters.push(Box::new(empty::EmptyReporter));
319      },
320      "blob" => {
321        let path = config
322          .options
323          .get("path")
324          .and_then(|v| v.as_str())
325          .map(std::path::PathBuf::from)
326          .unwrap_or_else(|| output_dir.join("report.zip"));
327        let mut reporter = blob::BlobReporter::new(path);
328        if let (Some(current), Some(total)) = (
329          config
330            .options
331            .get("shard_index")
332            .and_then(|v| v.as_u64())
333            .and_then(|v| u32::try_from(v).ok()),
334          config
335            .options
336            .get("shard_total")
337            .and_then(|v| v.as_u64())
338            .and_then(|v| u32::try_from(v).ok()),
339        ) {
340          reporter = reporter.with_shard(current, total);
341        }
342        reporters.push(Box::new(reporter));
343      },
344      "github" => {
345        // Wraps the terminal reporter so users see human-readable
346        // output AND the CI annotations from a single flag. The
347        // wrapped reporter respects `quiet`.
348        let inner: Box<dyn Reporter> = if quiet {
349          Box::new(empty::EmptyReporter)
350        } else {
351          Box::new(terminal::TerminalReporter::new().with_slow_tests_config(report_slow_tests.clone()))
352        };
353        let mut reporter = github::GithubReporter::new(inner);
354        if let Some(force) = config.options.get("enabled").and_then(|v| v.as_bool()) {
355          reporter = reporter.with_enabled(force);
356        }
357        reporters.push(Box::new(reporter));
358      },
359
360      // ── Shared reporters (same for both modes) ──
361      "html" => {
362        reporters.push(Box::new(html::HtmlReporter::new(output_dir.join("report.html"))));
363      },
364      "allure" => {
365        let dir = config
366          .options
367          .get("output_dir")
368          .and_then(|v| v.as_str())
369          .map(std::path::PathBuf::from)
370          .unwrap_or_else(|| output_dir.join("allure-results"));
371        let mut reporter = allure::AllureReporter::new(dir);
372        if let Some(title) = config.options.get("suite_title").and_then(|v| v.as_str()) {
373          reporter = reporter.with_suite_title(title.to_string());
374        }
375        reporters.push(Box::new(reporter));
376      },
377      "progress" => {
378        reporters.push(Box::new(progress::ProgressReporter::new()));
379      },
380      "rerun" => {
381        reporters.push(Box::new(rerun::RerunReporter::new(output_dir.join("@rerun.txt"))));
382      },
383
384      // ── BDD-specific reporters (usable in any mode) ──
385      "cucumber-json" | "cucumber" => {
386        reporters.push(Box::new(bdd::cucumber_json::CucumberJsonReporter::new(
387          output_dir.join("cucumber.json"),
388        )));
389      },
390      "messages" | "ndjson" => {
391        reporters.push(Box::new(bdd::messages::CucumberMessagesReporter::new(
392          output_dir.join("cucumber-messages.ndjson"),
393        )));
394      },
395      "usage" => {
396        reporters.push(Box::new(bdd::usage::UsageReporter::new()));
397      },
398
399      other => {
400        tracing::warn!("unknown reporter: {other}, skipping");
401      },
402    }
403  }
404
405  if reporters.is_empty() {
406    reporters.push(Box::new(terminal::TerminalReporter::new()));
407  }
408
409  // Always add the rerun reporter so @rerun.txt is available for --last-failed.
410  let has_rerun = names.iter().any(|c| c.name == "rerun");
411  if !has_rerun {
412    reporters.push(Box::new(rerun::RerunReporter::new(output_dir.join("@rerun.txt"))));
413  }
414
415  ReporterSet::new(reporters)
416}