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