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  /// Append an additional reporter (e.g., NAPI ResultCollector).
112  pub fn add(&mut self, reporter: Box<dyn Reporter>) {
113    self.reporters.push(reporter);
114  }
115
116  /// Replace all reporters with a new set.
117  pub fn replace(&mut self, reporters: Vec<Box<dyn Reporter>>) {
118    self.reporters = reporters;
119  }
120
121  pub async fn emit(&mut self, event: &ReporterEvent) {
122    for reporter in &mut self.reporters {
123      reporter.on_event(event).await;
124    }
125  }
126
127  pub async fn finalize(&mut self) {
128    for reporter in &mut self.reporters {
129      if let Err(e) = reporter.finalize().await {
130        tracing::error!("reporter finalize error: {e}");
131      }
132    }
133  }
134}
135
136// ── Event Bus ──
137
138/// Builder for constructing an `EventBus` with registered subscribers.
139///
140/// Register all subscribers before calling `build()`. Once built, the bus
141/// is immutable — no new subscribers can be added. This ensures workers
142/// (which clone the bus) fan out to a fixed set of consumers.
143pub struct EventBusBuilder {
144  subscribers: Vec<mpsc::UnboundedSender<ReporterEvent>>,
145}
146
147impl Default for EventBusBuilder {
148  fn default() -> Self {
149    Self::new()
150  }
151}
152
153impl EventBusBuilder {
154  pub fn new() -> Self {
155    Self {
156      subscribers: Vec::new(),
157    }
158  }
159
160  /// Register a subscriber. Returns a `Subscription` (the receiving end).
161  /// Must be called before `build()`.
162  pub fn subscribe(&mut self) -> Subscription {
163    let (tx, rx) = mpsc::unbounded_channel();
164    self.subscribers.push(tx);
165    Subscription { rx }
166  }
167
168  /// Finalize the bus. No more subscribers can be added after this.
169  pub fn build(self) -> EventBus {
170    EventBus {
171      inner: Arc::new(EventBusInner {
172        subscribers: std::sync::RwLock::new(self.subscribers),
173      }),
174    }
175  }
176}
177
178/// The receiving end of a subscriber channel.
179pub struct Subscription {
180  pub rx: mpsc::UnboundedReceiver<ReporterEvent>,
181}
182
183/// Fan-out event bus. Workers clone this and call `emit()` — events are
184/// delivered to all subscribers registered at build time.
185///
186/// Clone is cheap (Arc internals). All clones share the same subscriber list.
187#[derive(Clone)]
188pub struct EventBus {
189  inner: Arc<EventBusInner>,
190}
191
192struct EventBusInner {
193  /// Subscriber channels — frozen after build. Read-only during emit (no lock needed).
194  /// `close()` swaps to empty Vec via `std::sync::RwLock` (write only on shutdown).
195  subscribers: std::sync::RwLock<Vec<mpsc::UnboundedSender<ReporterEvent>>>,
196}
197
198impl EventBus {
199  /// Emit an event to all subscribers. Lock-free read path — `RwLock::read()` never
200  /// blocks other readers. Only `close()` takes a write lock (once, at shutdown).
201  pub async fn emit(&self, event: ReporterEvent) {
202    let subs = self.inner.subscribers.read().expect("EventBus RwLock poisoned");
203    if subs.is_empty() {
204      return;
205    }
206    let last = subs.len() - 1;
207    for sub in &subs[..last] {
208      let _ = sub.send(event.clone());
209    }
210    let _ = subs[last].send(event);
211  }
212
213  /// Explicitly close all sender channels.
214  pub fn close(&self) {
215    self
216      .inner
217      .subscribers
218      .write()
219      .expect("EventBus RwLock poisoned")
220      .clear();
221  }
222}
223
224// ── Reporter Driver ──
225
226/// Standalone consumer that drains a `Subscription` and drives a `ReporterSet`.
227/// Decoupled from test execution — can run as an independent tokio task.
228///
229/// Spawn this with `tokio::spawn(driver.run())`. When the event bus is dropped
230/// (all senders gone), the subscription channel closes, the driver finalizes
231/// all reporters, and returns the `ReporterSet` for potential reuse.
232pub struct ReporterDriver {
233  reporters: ReporterSet,
234  subscription: Subscription,
235}
236
237impl ReporterDriver {
238  pub fn new(reporters: ReporterSet, subscription: Subscription) -> Self {
239    Self {
240      reporters,
241      subscription,
242    }
243  }
244
245  /// Consume events until the channel closes, finalize reporters, return them.
246  pub async fn run(mut self) -> ReporterSet {
247    while let Some(event) = self.subscription.rx.recv().await {
248      self.reporters.emit(&event).await;
249    }
250    self.reporters.finalize().await;
251    self.reporters
252  }
253}
254
255// ── Factory ──
256
257/// Unified reporter factory. Creates reporters from config names, routing
258/// mode-dependent reporters (terminal, json, junit) based on `mode`.
259pub fn create_reporters_pub(
260  names: &[crate::config::ReporterConfig],
261  output_dir: &std::path::Path,
262  has_bdd: bool,
263  quiet: bool,
264  report_slow_tests: Option<crate::config::ReportSlowTestsConfig>,
265) -> ReporterSet {
266  create_reporters(names, output_dir, has_bdd, quiet, report_slow_tests)
267}
268
269pub(crate) fn create_reporters(
270  names: &[crate::config::ReporterConfig],
271  output_dir: &std::path::Path,
272  _has_bdd: bool,
273  quiet: bool,
274  report_slow_tests: Option<crate::config::ReportSlowTestsConfig>,
275) -> ReporterSet {
276  let mut reporters: Vec<Box<dyn Reporter>> = Vec::new();
277  let mut has_terminal = false;
278
279  for config in names {
280    match config.name.as_str() {
281      // Terminal reporter handles both E2E and BDD — detects BDD by step metadata.
282      "terminal" | "list" | "bdd" | "default" | "" => {
283        if !has_terminal && !quiet {
284          reporters.push(Box::new(
285            terminal::TerminalReporter::new().with_slow_tests_config(report_slow_tests.clone()),
286          ));
287          has_terminal = true;
288        }
289      },
290      "json" => {
291        reporters.push(Box::new(json::JsonReporter::new(output_dir.join("results.json"))));
292      },
293      "junit" => {
294        reporters.push(Box::new(junit::JUnitReporter::new(output_dir.join("junit.xml"))));
295      },
296      "dot" => {
297        reporters.push(Box::new(dot::DotReporter::new()));
298      },
299      "null" | "empty" => {
300        reporters.push(Box::new(empty::EmptyReporter));
301      },
302      "blob" => {
303        let path = config
304          .options
305          .get("path")
306          .and_then(|v| v.as_str())
307          .map(std::path::PathBuf::from)
308          .unwrap_or_else(|| output_dir.join("report.zip"));
309        let mut reporter = blob::BlobReporter::new(path);
310        if let (Some(current), Some(total)) = (
311          config
312            .options
313            .get("shard_index")
314            .and_then(|v| v.as_u64())
315            .and_then(|v| u32::try_from(v).ok()),
316          config
317            .options
318            .get("shard_total")
319            .and_then(|v| v.as_u64())
320            .and_then(|v| u32::try_from(v).ok()),
321        ) {
322          reporter = reporter.with_shard(current, total);
323        }
324        reporters.push(Box::new(reporter));
325      },
326      "github" => {
327        // Wraps the terminal reporter so users see human-readable
328        // output AND the CI annotations from a single flag. The
329        // wrapped reporter respects `quiet`.
330        let inner: Box<dyn Reporter> = if quiet {
331          Box::new(empty::EmptyReporter)
332        } else {
333          Box::new(terminal::TerminalReporter::new().with_slow_tests_config(report_slow_tests.clone()))
334        };
335        let mut reporter = github::GithubReporter::new(inner);
336        if let Some(force) = config.options.get("enabled").and_then(|v| v.as_bool()) {
337          reporter = reporter.with_enabled(force);
338        }
339        reporters.push(Box::new(reporter));
340      },
341
342      // ── Shared reporters (same for both modes) ──
343      "html" => {
344        reporters.push(Box::new(html::HtmlReporter::new(output_dir.join("report.html"))));
345      },
346      "allure" => {
347        let dir = config
348          .options
349          .get("output_dir")
350          .and_then(|v| v.as_str())
351          .map(std::path::PathBuf::from)
352          .unwrap_or_else(|| output_dir.join("allure-results"));
353        let mut reporter = allure::AllureReporter::new(dir);
354        if let Some(title) = config.options.get("suite_title").and_then(|v| v.as_str()) {
355          reporter = reporter.with_suite_title(title.to_string());
356        }
357        reporters.push(Box::new(reporter));
358      },
359      "progress" => {
360        reporters.push(Box::new(progress::ProgressReporter::new()));
361      },
362      "rerun" => {
363        reporters.push(Box::new(rerun::RerunReporter::new(output_dir.join("@rerun.txt"))));
364      },
365
366      // ── BDD-specific reporters (usable in any mode) ──
367      "cucumber-json" | "cucumber" => {
368        reporters.push(Box::new(bdd::cucumber_json::CucumberJsonReporter::new(
369          output_dir.join("cucumber.json"),
370        )));
371      },
372      "messages" | "ndjson" => {
373        reporters.push(Box::new(bdd::messages::CucumberMessagesReporter::new(
374          output_dir.join("cucumber-messages.ndjson"),
375        )));
376      },
377      "usage" => {
378        reporters.push(Box::new(bdd::usage::UsageReporter::new()));
379      },
380
381      other => {
382        tracing::warn!("unknown reporter: {other}, skipping");
383      },
384    }
385  }
386
387  if reporters.is_empty() {
388    reporters.push(Box::new(terminal::TerminalReporter::new()));
389  }
390
391  // Always add the rerun reporter so @rerun.txt is available for --last-failed.
392  let has_rerun = names.iter().any(|c| c.name == "rerun");
393  if !has_rerun {
394    reporters.push(Box::new(rerun::RerunReporter::new(output_dir.join("@rerun.txt"))));
395  }
396
397  ReporterSet::new(reporters)
398}