ferridriver_test/reporter/
mod.rs1pub 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#[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 pub metadata: Option<serde_json::Value>,
44}
45
46#[derive(Debug, Clone)]
48pub enum ReporterEvent {
49 RunStarted {
51 total_tests: usize,
52 num_workers: u32,
53 metadata: serde_json::Value,
55 },
56 WorkerStarted { worker_id: u32 },
58 TestStarted { test_id: TestId, attempt: u32 },
60 StepStarted(Box<StepStartedEvent>),
62 StepFinished(Box<StepFinishedEvent>),
64 TestFinished { test_id: TestId, outcome: TestOutcome },
66 WorkerFinished { worker_id: u32 },
68 RunFinished {
70 total: usize,
71 passed: usize,
72 failed: usize,
73 skipped: usize,
74 flaky: usize,
75 duration: Duration,
76 },
77}
78
79#[async_trait::async_trait]
83pub trait Reporter: Send + Sync {
84 async fn on_event(&mut self, event: &ReporterEvent);
86
87 async fn finalize(&mut self) -> ferridriver::error::Result<()> {
89 Ok(())
90 }
91}
92
93pub 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 add(&mut self, reporter: Box<dyn Reporter>) {
113 self.reporters.push(reporter);
114 }
115
116 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
136pub 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 pub fn subscribe(&mut self) -> Subscription {
163 let (tx, rx) = mpsc::unbounded_channel();
164 self.subscribers.push(tx);
165 Subscription { rx }
166 }
167
168 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
178pub struct Subscription {
180 pub rx: mpsc::UnboundedReceiver<ReporterEvent>,
181}
182
183#[derive(Clone)]
188pub struct EventBus {
189 inner: Arc<EventBusInner>,
190}
191
192struct EventBusInner {
193 subscribers: std::sync::RwLock<Vec<mpsc::UnboundedSender<ReporterEvent>>>,
196}
197
198impl EventBus {
199 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 pub fn close(&self) {
215 self
216 .inner
217 .subscribers
218 .write()
219 .expect("EventBus RwLock poisoned")
220 .clear();
221 }
222}
223
224pub 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 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
255pub 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" | "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 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 "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 "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 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}