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 is_empty(&self) -> bool {
112 self.reporters.is_empty()
113 }
114
115 pub fn add(&mut self, reporter: Box<dyn Reporter>) {
117 self.reporters.push(reporter);
118 }
119
120 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
140pub 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 pub fn subscribe(&mut self) -> Subscription {
167 let (tx, rx) = mpsc::unbounded_channel();
168 self.subscribers.push(tx);
169 Subscription { rx }
170 }
171
172 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
184pub struct Subscription {
186 pub rx: mpsc::UnboundedReceiver<ReporterEvent>,
187}
188
189#[derive(Clone)]
194pub struct EventBus {
195 inner: Arc<EventBusInner>,
196}
197
198struct EventBusInner {
199 has_subscribers: bool,
200 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 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 pub fn close(&self) {
229 self
230 .inner
231 .subscribers
232 .write()
233 .expect("EventBus RwLock poisoned")
234 .clear();
235 }
236}
237
238pub 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 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
269pub 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" | "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 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 "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 "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 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}