Skip to main content

testx/plugin/reporters/
github.rs

1//! GitHub Actions reporter plugin.
2//!
3//! Outputs GitHub Actions workflow commands for annotations,
4//! grouping, and step summaries.
5
6use std::fmt::Write;
7use std::time::Duration;
8
9use crate::adapters::{TestRunResult, TestStatus, TestSuite};
10use crate::error;
11use crate::events::TestEvent;
12use crate::plugin::Plugin;
13
14/// GitHub Actions reporter configuration.
15#[derive(Debug, Clone)]
16pub struct GithubConfig {
17    /// Emit `::error` / `::warning` annotations for failures
18    pub annotations: bool,
19    /// Use `::group` / `::endgroup` for suite output
20    pub groups: bool,
21    /// Write a step summary to `$GITHUB_STEP_SUMMARY`
22    pub step_summary: bool,
23    /// Inject problem matcher pattern
24    pub problem_matcher: bool,
25}
26
27impl Default for GithubConfig {
28    fn default() -> Self {
29        Self {
30            annotations: true,
31            groups: true,
32            step_summary: true,
33            problem_matcher: false,
34        }
35    }
36}
37
38/// GitHub Actions reporter plugin.
39pub struct GithubReporter {
40    config: GithubConfig,
41    collected: Vec<String>,
42}
43
44impl GithubReporter {
45    pub fn new(config: GithubConfig) -> Self {
46        Self {
47            config,
48            collected: Vec::new(),
49        }
50    }
51
52    /// Get the collected output lines.
53    pub fn output(&self) -> &[String] {
54        &self.collected
55    }
56}
57
58impl Plugin for GithubReporter {
59    fn name(&self) -> &str {
60        "github"
61    }
62
63    fn version(&self) -> &str {
64        "1.0.0"
65    }
66
67    fn on_event(&mut self, _event: &TestEvent) -> error::Result<()> {
68        Ok(())
69    }
70
71    fn on_result(&mut self, result: &TestRunResult) -> error::Result<()> {
72        self.collected = generate_github_output(result, &self.config);
73        Ok(())
74    }
75}
76
77/// Generate GitHub Actions workflow commands from test results.
78pub fn generate_github_output(result: &TestRunResult, config: &GithubConfig) -> Vec<String> {
79    let mut lines = Vec::new();
80
81    if config.problem_matcher {
82        write_problem_matcher(&mut lines);
83    }
84
85    for suite in &result.suites {
86        if config.groups {
87            write_group(&mut lines, suite);
88        }
89        if config.annotations {
90            write_annotations(&mut lines, suite);
91        }
92    }
93
94    if config.step_summary {
95        write_step_summary_commands(&mut lines, result);
96    }
97
98    // Final outcome line
99    let status = if result.is_success() {
100        "passed"
101    } else {
102        "failed"
103    };
104    lines.push(format!(
105        "::notice::testx: {} tests {status} ({} passed, {} failed, {} skipped) in {}",
106        result.total_tests(),
107        result.total_passed(),
108        result.total_failed(),
109        result.total_skipped(),
110        format_duration(result.duration),
111    ));
112
113    lines
114}
115
116fn write_problem_matcher(lines: &mut Vec<String>) {
117    lines.push("::add-matcher::testx-matcher.json".into());
118}
119
120fn write_group(lines: &mut Vec<String>, suite: &TestSuite) {
121    let icon = if suite.is_passed() { "✅" } else { "❌" };
122    lines.push(format!(
123        "::group::{icon} {} ({} tests, {} failed)",
124        suite.name,
125        suite.tests.len(),
126        suite.failed(),
127    ));
128
129    for test in &suite.tests {
130        let icon = match test.status {
131            TestStatus::Passed => "",
132            TestStatus::Failed => "",
133            TestStatus::Skipped => "⏭️",
134        };
135        lines.push(format!("  {icon} {} ({:?})", test.name, test.duration));
136    }
137
138    lines.push("::endgroup::".into());
139}
140
141fn write_annotations(lines: &mut Vec<String>, suite: &TestSuite) {
142    for test in suite.failures() {
143        let msg = test
144            .error
145            .as_ref()
146            .map(|e| e.message.clone())
147            .unwrap_or_else(|| "test failed".into());
148
149        // Extract file/line from error location if available
150        if let Some(ref error) = test.error
151            && let Some(ref loc) = error.location
152            && let Some((file, line)) = parse_location(loc)
153        {
154            lines.push(format!(
155                "::error file={file},line={line},title={}::{}",
156                escape_workflow_value(&test.name),
157                escape_workflow_value(&msg),
158            ));
159            continue;
160        }
161
162        lines.push(format!(
163            "::error title={} ({})::{msg}",
164            escape_workflow_value(&test.name),
165            suite.name,
166        ));
167    }
168}
169
170fn write_step_summary_commands(lines: &mut Vec<String>, result: &TestRunResult) {
171    let mut md = String::with_capacity(1024);
172    let icon = if result.is_success() {
173        " Passed"
174    } else {
175        " Failed"
176    };
177
178    let _ = writeln!(md, "### Test Results — {icon}");
179    md.push('\n');
180    let _ = writeln!(md, "| Total | Passed | Failed | Skipped | Duration |");
181    let _ = writeln!(md, "| ----- | ------ | ------ | ------- | -------- |");
182    let _ = writeln!(
183        md,
184        "| {} | {} | {} | {} | {} |",
185        result.total_tests(),
186        result.total_passed(),
187        result.total_failed(),
188        result.total_skipped(),
189        format_duration(result.duration),
190    );
191
192    if result.total_failed() > 0 {
193        md.push('\n');
194        let _ = writeln!(md, "#### Failures");
195        md.push('\n');
196        for suite in &result.suites {
197            for test in suite.failures() {
198                let msg = test
199                    .error
200                    .as_ref()
201                    .map(|e| e.message.clone())
202                    .unwrap_or_else(|| "test failed".into());
203                let _ = writeln!(md, "- **{}::{}**: {}", suite.name, test.name, msg);
204            }
205        }
206    }
207
208    // Output as GITHUB_STEP_SUMMARY echo commands
209    for line in md.lines() {
210        let escaped = line.replace('`', "\\`");
211        lines.push(format!("echo '{escaped}' >> $GITHUB_STEP_SUMMARY"));
212    }
213}
214
215/// Parse a location string like "file.rs:42" or "file.rs:42:10".
216fn parse_location(loc: &str) -> Option<(String, String)> {
217    // Try "file:line:col" first, then "file:line"
218    let parts: Vec<&str> = loc.rsplitn(3, ':').collect();
219    if parts.len() == 3
220        && parts[0].chars().all(|c| c.is_ascii_digit())
221        && parts[1].chars().all(|c| c.is_ascii_digit())
222    {
223        // file:line:col — return (file, line)
224        return Some((parts[2].to_string(), parts[1].to_string()));
225    }
226    if parts.len() >= 2 && parts[0].chars().all(|c| c.is_ascii_digit()) && !parts[0].is_empty() {
227        // file:line
228        let line = parts[0];
229        let file = &loc[..loc.len() - line.len() - 1];
230        return Some((file.to_string(), line.to_string()));
231    }
232    None
233}
234
235/// Escape a string for use in workflow commands (`%0A`, `%25`, etc.).
236fn escape_workflow_value(s: &str) -> String {
237    s.replace('%', "%25")
238        .replace('\r', "%0D")
239        .replace('\n', "%0A")
240}
241
242fn format_duration(d: Duration) -> String {
243    let ms = d.as_millis();
244    if ms == 0 {
245        "<1ms".to_string()
246    } else if ms < 1000 {
247        format!("{ms}ms")
248    } else {
249        format!("{:.2}s", d.as_secs_f64())
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::adapters::{TestCase, TestError, TestSuite};
257
258    fn make_test(name: &str, status: TestStatus, ms: u64) -> TestCase {
259        TestCase {
260            name: name.into(),
261            status,
262            duration: Duration::from_millis(ms),
263            error: None,
264        }
265    }
266
267    fn make_failed_test(name: &str, ms: u64, msg: &str, loc: Option<&str>) -> TestCase {
268        TestCase {
269            name: name.into(),
270            status: TestStatus::Failed,
271            duration: Duration::from_millis(ms),
272            error: Some(TestError {
273                message: msg.into(),
274                location: loc.map(String::from),
275            }),
276        }
277    }
278
279    fn make_result() -> TestRunResult {
280        TestRunResult {
281            suites: vec![
282                TestSuite {
283                    name: "math".into(),
284                    tests: vec![
285                        make_test("add", TestStatus::Passed, 10),
286                        make_failed_test("div", 5, "divide by zero", Some("math.rs:42")),
287                    ],
288                },
289                TestSuite {
290                    name: "strings".into(),
291                    tests: vec![
292                        make_test("concat", TestStatus::Passed, 15),
293                        make_test("upper", TestStatus::Skipped, 0),
294                    ],
295                },
296            ],
297            duration: Duration::from_millis(300),
298            raw_exit_code: 1,
299        }
300    }
301
302    #[test]
303    fn github_groups() {
304        let lines = generate_github_output(&make_result(), &GithubConfig::default());
305        assert!(lines.iter().any(|l| l.starts_with("::group::")));
306        assert!(lines.iter().any(|l| l == "::endgroup::"));
307    }
308
309    #[test]
310    fn github_annotations() {
311        let lines = generate_github_output(&make_result(), &GithubConfig::default());
312        let error_lines: Vec<_> = lines.iter().filter(|l| l.starts_with("::error")).collect();
313        assert_eq!(error_lines.len(), 1);
314        assert!(error_lines[0].contains("file=math.rs"));
315        assert!(error_lines[0].contains("line=42"));
316    }
317
318    #[test]
319    fn github_annotation_without_location() {
320        let result = TestRunResult {
321            suites: vec![TestSuite {
322                name: "t".into(),
323                tests: vec![make_failed_test("f1", 1, "boom", None)],
324            }],
325            duration: Duration::from_millis(10),
326            raw_exit_code: 1,
327        };
328        let lines = generate_github_output(&result, &GithubConfig::default());
329        let error_lines: Vec<_> = lines.iter().filter(|l| l.starts_with("::error")).collect();
330        assert_eq!(error_lines.len(), 1);
331        assert!(error_lines[0].contains("title=f1"));
332    }
333
334    #[test]
335    fn github_step_summary() {
336        let lines = generate_github_output(&make_result(), &GithubConfig::default());
337        let summary_lines: Vec<_> = lines
338            .iter()
339            .filter(|l| l.contains("GITHUB_STEP_SUMMARY"))
340            .collect();
341        assert!(!summary_lines.is_empty());
342        assert!(summary_lines.iter().any(|l| l.contains("Test Results")));
343    }
344
345    #[test]
346    fn github_notice_line() {
347        let lines = generate_github_output(&make_result(), &GithubConfig::default());
348        let notice = lines.iter().find(|l| l.starts_with("::notice::")).unwrap();
349        assert!(notice.contains("4 tests failed"));
350    }
351
352    #[test]
353    fn github_passing_notice() {
354        let result = TestRunResult {
355            suites: vec![TestSuite {
356                name: "t".into(),
357                tests: vec![make_test("t1", TestStatus::Passed, 1)],
358            }],
359            duration: Duration::from_millis(10),
360            raw_exit_code: 0,
361        };
362        let lines = generate_github_output(&result, &GithubConfig::default());
363        let notice = lines.iter().find(|l| l.starts_with("::notice::")).unwrap();
364        assert!(notice.contains("passed"));
365    }
366
367    #[test]
368    fn github_problem_matcher() {
369        let config = GithubConfig {
370            problem_matcher: true,
371            ..Default::default()
372        };
373        let lines = generate_github_output(&make_result(), &config);
374        assert!(lines[0].contains("add-matcher"));
375    }
376
377    #[test]
378    fn github_no_groups() {
379        let config = GithubConfig {
380            groups: false,
381            ..Default::default()
382        };
383        let lines = generate_github_output(&make_result(), &config);
384        assert!(!lines.iter().any(|l| l.starts_with("::group::")));
385    }
386
387    #[test]
388    fn github_plugin_trait() {
389        let mut reporter = GithubReporter::new(GithubConfig::default());
390        assert_eq!(reporter.name(), "github");
391        reporter.on_result(&make_result()).unwrap();
392        assert!(!reporter.output().is_empty());
393    }
394
395    #[test]
396    fn parse_location_simple() {
397        let (file, line) = parse_location("test.rs:42").unwrap();
398        assert_eq!(file, "test.rs");
399        assert_eq!(line, "42");
400    }
401
402    #[test]
403    fn parse_location_with_column() {
404        let (file, line) = parse_location("test.rs:42:10").unwrap();
405        assert_eq!(file, "test.rs");
406        assert_eq!(line, "42");
407    }
408
409    #[test]
410    fn parse_location_invalid() {
411        assert!(parse_location("no_colon").is_none());
412    }
413
414    #[test]
415    fn escape_workflow_newlines() {
416        let escaped = escape_workflow_value("line1\nline2");
417        assert_eq!(escaped, "line1%0Aline2");
418    }
419
420    #[test]
421    fn escape_workflow_percent() {
422        let escaped = escape_workflow_value("100%");
423        assert_eq!(escaped, "100%25");
424    }
425}