Skip to main content

ferridriver_test/reporter/
github.rs

1//! `github` reporter — emits GitHub Actions `::error` annotations
2//! for every failing test in addition to forwarding events to a
3//! wrapped reporter. Mirrors Playwright's
4//! `/tmp/playwright/packages/playwright/src/reporters/github.ts`.
5
6use async_trait::async_trait;
7
8use super::{Reporter, ReporterEvent};
9use crate::model::TestStatus;
10
11/// GitHub Actions reporter. Wraps a delegate (typically the terminal
12/// reporter) and additionally emits
13/// `::error file=...,line=...,title=...::message` lines so failures
14/// show up as inline annotations on the PR.
15///
16/// The delegate is preserved so users get human-readable output AND
17/// CI annotations from the same `--reporter github` flag.
18pub struct GithubReporter {
19  delegate: Box<dyn Reporter>,
20  enabled: bool,
21}
22
23impl GithubReporter {
24  /// Wrap a delegate reporter. `enabled` is read from the
25  /// `GITHUB_ACTIONS` env var at construction time — outside of CI
26  /// the reporter is a transparent passthrough so local runs aren't
27  /// polluted with annotation lines.
28  #[must_use]
29  pub fn new(delegate: Box<dyn Reporter>) -> Self {
30    let enabled = std::env::var("GITHUB_ACTIONS").is_ok();
31    Self { delegate, enabled }
32  }
33
34  /// Force the annotations on/off — for tests.
35  pub fn with_enabled(mut self, enabled: bool) -> Self {
36    self.enabled = enabled;
37    self
38  }
39}
40
41#[async_trait]
42impl Reporter for GithubReporter {
43  async fn on_event(&mut self, event: &ReporterEvent) {
44    if self.enabled {
45      if let ReporterEvent::TestFinished { test_id, outcome } = event {
46        if matches!(outcome.status, TestStatus::Failed | TestStatus::TimedOut) {
47          let title = test_id.full_name().replace(['\r', '\n'], " ");
48          let message = outcome
49            .error
50            .as_ref()
51            .map(|e| escape(&e.message))
52            .unwrap_or_else(|| "test failed".to_string());
53          let file = test_id.file.replace(['\r', '\n'], " ");
54          let line = test_id.line.unwrap_or(1);
55          // GitHub Actions workflow command syntax:
56          // ::error file={path},line={n},title={title}::{message}
57          println!("::error file={file},line={line},title={title}::{message}");
58        }
59      }
60    }
61    self.delegate.on_event(event).await;
62  }
63
64  async fn finalize(&mut self) -> ferridriver::error::Result<()> {
65    self.delegate.finalize().await
66  }
67}
68
69fn escape(s: &str) -> String {
70  // GitHub workflow commands escape `%`, `\r`, and `\n` per
71  // https://docs.github.com/en/actions/learn-github-actions/workflow-commands-for-github-actions#example-create-a-warning-message
72  s.replace('%', "%25").replace('\r', "%0D").replace('\n', "%0A")
73}