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={} ({})::{}",
164            escape_workflow_value(&test.name),
165            suite.name,
166            escape_workflow_value(&msg),
167        ));
168    }
169}
170
171fn write_step_summary_commands(lines: &mut Vec<String>, result: &TestRunResult) {
172    let mut md = String::with_capacity(1024);
173    let icon = if result.is_success() {
174        " Passed"
175    } else {
176        " Failed"
177    };
178
179    let _ = writeln!(md, "### Test Results — {icon}");
180    md.push('\n');
181    let _ = writeln!(md, "| Total | Passed | Failed | Skipped | Duration |");
182    let _ = writeln!(md, "| ----- | ------ | ------ | ------- | -------- |");
183    let _ = writeln!(
184        md,
185        "| {} | {} | {} | {} | {} |",
186        result.total_tests(),
187        result.total_passed(),
188        result.total_failed(),
189        result.total_skipped(),
190        format_duration(result.duration),
191    );
192
193    if result.total_failed() > 0 {
194        md.push('\n');
195        let _ = writeln!(md, "#### Failures");
196        md.push('\n');
197        for suite in &result.suites {
198            for test in suite.failures() {
199                let msg = test
200                    .error
201                    .as_ref()
202                    .map(|e| e.message.clone())
203                    .unwrap_or_else(|| "test failed".into());
204                let _ = writeln!(md, "- **{}::{}**: {}", suite.name, test.name, msg);
205            }
206        }
207    }
208
209    // Output as GITHUB_STEP_SUMMARY echo commands
210    for line in md.lines() {
211        let escaped = line.replace('`', "\\`");
212        lines.push(format!("echo '{escaped}' >> $GITHUB_STEP_SUMMARY"));
213    }
214}
215
216/// Parse a location string like "file.rs:42" or "file.rs:42:10".
217fn parse_location(loc: &str) -> Option<(String, String)> {
218    // Try "file:line:col" first, then "file:line"
219    let parts: Vec<&str> = loc.rsplitn(3, ':').collect();
220    if parts.len() == 3
221        && parts[0].chars().all(|c| c.is_ascii_digit())
222        && parts[1].chars().all(|c| c.is_ascii_digit())
223    {
224        // file:line:col — return (file, line)
225        return Some((parts[2].to_string(), parts[1].to_string()));
226    }
227    if parts.len() >= 2 && parts[0].chars().all(|c| c.is_ascii_digit()) && !parts[0].is_empty() {
228        // file:line
229        let line = parts[0];
230        let file = &loc[..loc.len() - line.len() - 1];
231        return Some((file.to_string(), line.to_string()));
232    }
233    None
234}
235
236/// Escape a string for use in workflow commands (`%0A`, `%25`, etc.).
237fn escape_workflow_value(s: &str) -> String {
238    s.replace('%', "%25")
239        .replace('\r', "%0D")
240        .replace('\n', "%0A")
241        .replace(':', "%3A")
242        .replace(',', "%2C")
243}
244
245fn format_duration(d: Duration) -> String {
246    let ms = d.as_millis();
247    if ms == 0 {
248        "<1ms".to_string()
249    } else if ms < 1000 {
250        format!("{ms}ms")
251    } else {
252        format!("{:.2}s", d.as_secs_f64())
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::adapters::{TestCase, TestError, TestSuite};
260
261    fn make_test(name: &str, status: TestStatus, ms: u64) -> TestCase {
262        TestCase {
263            name: name.into(),
264            status,
265            duration: Duration::from_millis(ms),
266            error: None,
267        }
268    }
269
270    fn make_failed_test(name: &str, ms: u64, msg: &str, loc: Option<&str>) -> TestCase {
271        TestCase {
272            name: name.into(),
273            status: TestStatus::Failed,
274            duration: Duration::from_millis(ms),
275            error: Some(TestError {
276                message: msg.into(),
277                location: loc.map(String::from),
278            }),
279        }
280    }
281
282    fn make_result() -> TestRunResult {
283        TestRunResult {
284            suites: vec![
285                TestSuite {
286                    name: "math".into(),
287                    tests: vec![
288                        make_test("add", TestStatus::Passed, 10),
289                        make_failed_test("div", 5, "divide by zero", Some("math.rs:42")),
290                    ],
291                },
292                TestSuite {
293                    name: "strings".into(),
294                    tests: vec![
295                        make_test("concat", TestStatus::Passed, 15),
296                        make_test("upper", TestStatus::Skipped, 0),
297                    ],
298                },
299            ],
300            duration: Duration::from_millis(300),
301            raw_exit_code: 1,
302        }
303    }
304
305    #[test]
306    fn github_groups() {
307        let lines = generate_github_output(&make_result(), &GithubConfig::default());
308        assert!(lines.iter().any(|l| l.starts_with("::group::")));
309        assert!(lines.iter().any(|l| l == "::endgroup::"));
310    }
311
312    #[test]
313    fn github_annotations() {
314        let lines = generate_github_output(&make_result(), &GithubConfig::default());
315        let error_lines: Vec<_> = lines.iter().filter(|l| l.starts_with("::error")).collect();
316        assert_eq!(error_lines.len(), 1);
317        assert!(error_lines[0].contains("file=math.rs"));
318        assert!(error_lines[0].contains("line=42"));
319    }
320
321    #[test]
322    fn github_annotation_without_location() {
323        let result = TestRunResult {
324            suites: vec![TestSuite {
325                name: "t".into(),
326                tests: vec![make_failed_test("f1", 1, "boom", None)],
327            }],
328            duration: Duration::from_millis(10),
329            raw_exit_code: 1,
330        };
331        let lines = generate_github_output(&result, &GithubConfig::default());
332        let error_lines: Vec<_> = lines.iter().filter(|l| l.starts_with("::error")).collect();
333        assert_eq!(error_lines.len(), 1);
334        assert!(error_lines[0].contains("title=f1"));
335    }
336
337    #[test]
338    fn github_step_summary() {
339        let lines = generate_github_output(&make_result(), &GithubConfig::default());
340        let summary_lines: Vec<_> = lines
341            .iter()
342            .filter(|l| l.contains("GITHUB_STEP_SUMMARY"))
343            .collect();
344        assert!(!summary_lines.is_empty());
345        assert!(summary_lines.iter().any(|l| l.contains("Test Results")));
346    }
347
348    #[test]
349    fn github_notice_line() {
350        let lines = generate_github_output(&make_result(), &GithubConfig::default());
351        let notice = lines.iter().find(|l| l.starts_with("::notice::")).unwrap();
352        assert!(notice.contains("4 tests failed"));
353    }
354
355    #[test]
356    fn github_passing_notice() {
357        let result = TestRunResult {
358            suites: vec![TestSuite {
359                name: "t".into(),
360                tests: vec![make_test("t1", TestStatus::Passed, 1)],
361            }],
362            duration: Duration::from_millis(10),
363            raw_exit_code: 0,
364        };
365        let lines = generate_github_output(&result, &GithubConfig::default());
366        let notice = lines.iter().find(|l| l.starts_with("::notice::")).unwrap();
367        assert!(notice.contains("passed"));
368    }
369
370    #[test]
371    fn github_problem_matcher() {
372        let config = GithubConfig {
373            problem_matcher: true,
374            ..Default::default()
375        };
376        let lines = generate_github_output(&make_result(), &config);
377        assert!(lines[0].contains("add-matcher"));
378    }
379
380    #[test]
381    fn github_no_groups() {
382        let config = GithubConfig {
383            groups: false,
384            ..Default::default()
385        };
386        let lines = generate_github_output(&make_result(), &config);
387        assert!(!lines.iter().any(|l| l.starts_with("::group::")));
388    }
389
390    #[test]
391    fn github_plugin_trait() {
392        let mut reporter = GithubReporter::new(GithubConfig::default());
393        assert_eq!(reporter.name(), "github");
394        reporter.on_result(&make_result()).unwrap();
395        assert!(!reporter.output().is_empty());
396    }
397
398    #[test]
399    fn parse_location_simple() {
400        let (file, line) = parse_location("test.rs:42").unwrap();
401        assert_eq!(file, "test.rs");
402        assert_eq!(line, "42");
403    }
404
405    #[test]
406    fn parse_location_with_column() {
407        let (file, line) = parse_location("test.rs:42:10").unwrap();
408        assert_eq!(file, "test.rs");
409        assert_eq!(line, "42");
410    }
411
412    #[test]
413    fn parse_location_invalid() {
414        assert!(parse_location("no_colon").is_none());
415    }
416
417    #[test]
418    fn escape_workflow_newlines() {
419        let escaped = escape_workflow_value("line1\nline2");
420        assert_eq!(escaped, "line1%0Aline2");
421    }
422
423    #[test]
424    fn escape_workflow_percent() {
425        let escaped = escape_workflow_value("100%");
426        assert_eq!(escaped, "100%25");
427    }
428
429    // ─── Edge Case Tests ────────────────────────────────────────────────
430
431    #[test]
432    fn github_empty_result() {
433        let result = TestRunResult {
434            suites: vec![],
435            duration: Duration::ZERO,
436            raw_exit_code: 0,
437        };
438        let lines = generate_github_output(&result, &GithubConfig::default());
439        let notice = lines.iter().find(|l| l.starts_with("::notice::")).unwrap();
440        assert!(notice.contains("0 tests passed"));
441    }
442
443    #[test]
444    fn github_all_tests_passing() {
445        let result = TestRunResult {
446            suites: vec![TestSuite {
447                name: "suite".into(),
448                tests: vec![
449                    make_test("t1", TestStatus::Passed, 1),
450                    make_test("t2", TestStatus::Passed, 2),
451                ],
452            }],
453            duration: Duration::from_millis(10),
454            raw_exit_code: 0,
455        };
456        let lines = generate_github_output(&result, &GithubConfig::default());
457        // No ::error:: lines for passing tests
458        assert!(!lines.iter().any(|l| l.starts_with("::error")));
459        let notice = lines.iter().find(|l| l.starts_with("::notice::")).unwrap();
460        assert!(notice.contains("passed"));
461    }
462
463    #[test]
464    fn github_all_tests_failing() {
465        let result = TestRunResult {
466            suites: vec![TestSuite {
467                name: "s".into(),
468                tests: vec![
469                    make_failed_test("f1", 1, "err1", None),
470                    make_failed_test("f2", 2, "err2", Some("x.rs:1")),
471                ],
472            }],
473            duration: Duration::from_millis(10),
474            raw_exit_code: 1,
475        };
476        let lines = generate_github_output(&result, &GithubConfig::default());
477        let error_lines: Vec<_> = lines.iter().filter(|l| l.starts_with("::error")).collect();
478        assert_eq!(error_lines.len(), 2);
479    }
480
481    #[test]
482    fn github_step_summary_failures_listed() {
483        let lines = generate_github_output(&make_result(), &GithubConfig::default());
484        let summary_lines: Vec<_> = lines
485            .iter()
486            .filter(|l| l.contains("GITHUB_STEP_SUMMARY"))
487            .collect();
488        assert!(summary_lines.iter().any(|l| l.contains("Failures")));
489    }
490
491    #[test]
492    fn github_no_step_summary() {
493        let config = GithubConfig {
494            step_summary: false,
495            ..Default::default()
496        };
497        let lines = generate_github_output(&make_result(), &config);
498        assert!(!lines.iter().any(|l| l.contains("GITHUB_STEP_SUMMARY")));
499    }
500
501    #[test]
502    fn github_no_annotations() {
503        let config = GithubConfig {
504            annotations: false,
505            ..Default::default()
506        };
507        let lines = generate_github_output(&make_result(), &config);
508        assert!(!lines.iter().any(|l| l.starts_with("::error")));
509    }
510
511    #[test]
512    fn github_all_disabled() {
513        let config = GithubConfig {
514            annotations: false,
515            groups: false,
516            step_summary: false,
517            problem_matcher: false,
518        };
519        let lines = generate_github_output(&make_result(), &config);
520        // Should only have the final ::notice:: line
521        assert_eq!(lines.len(), 1);
522        assert!(lines[0].starts_with("::notice::"));
523    }
524
525    #[test]
526    fn github_skipped_test_in_group() {
527        let result = TestRunResult {
528            suites: vec![TestSuite {
529                name: "s".into(),
530                tests: vec![make_test("skipped", TestStatus::Skipped, 0)],
531            }],
532            duration: Duration::ZERO,
533            raw_exit_code: 0,
534        };
535        let lines = generate_github_output(&result, &GithubConfig::default());
536        let group_content: Vec<_> = lines
537            .iter()
538            .filter(|l| l.contains("skipped") && !l.starts_with("::notice"))
539            .collect();
540        assert!(!group_content.is_empty());
541    }
542
543    #[test]
544    fn github_annotation_newlines_escaped() {
545        let result = TestRunResult {
546            suites: vec![TestSuite {
547                name: "s".into(),
548                tests: vec![make_failed_test(
549                    "multi_line",
550                    1,
551                    "line1\nline2\nline3",
552                    None,
553                )],
554            }],
555            duration: Duration::ZERO,
556            raw_exit_code: 1,
557        };
558        let lines = generate_github_output(&result, &GithubConfig::default());
559        let error_line = lines.iter().find(|l| l.starts_with("::error")).unwrap();
560        // Newlines should be escaped as %0A
561        assert!(error_line.contains("%0A"));
562        assert!(!error_line.contains('\n') || error_line.matches('\n').count() == 0);
563    }
564
565    #[test]
566    fn github_location_with_column() {
567        let result = TestRunResult {
568            suites: vec![TestSuite {
569                name: "s".into(),
570                tests: vec![make_failed_test("t", 1, "err", Some("file.rs:10:5"))],
571            }],
572            duration: Duration::ZERO,
573            raw_exit_code: 1,
574        };
575        let lines = generate_github_output(&result, &GithubConfig::default());
576        let error_line = lines.iter().find(|l| l.starts_with("::error")).unwrap();
577        assert!(error_line.contains("file=file.rs"));
578        assert!(error_line.contains("line=10"));
579    }
580
581    #[test]
582    fn parse_location_just_filename() {
583        assert!(parse_location("nocolon").is_none());
584    }
585
586    #[test]
587    fn parse_location_non_numeric_after_colon() {
588        assert!(parse_location("file.rs:abc").is_none());
589    }
590
591    #[test]
592    fn parse_location_empty_line_number() {
593        assert!(parse_location("file.rs:").is_none());
594    }
595
596    #[test]
597    fn github_duration_formatting() {
598        let result = TestRunResult {
599            suites: vec![],
600            duration: Duration::from_secs(125),
601            raw_exit_code: 0,
602        };
603        let lines = generate_github_output(&result, &GithubConfig::default());
604        let notice = lines.iter().find(|l| l.starts_with("::notice::")).unwrap();
605        // Should format as seconds, not ms
606        assert!(notice.contains("s"));
607    }
608
609    #[test]
610    fn github_duration_less_than_1ms() {
611        assert_eq!(format_duration(Duration::ZERO), "<1ms");
612    }
613
614    #[test]
615    fn github_plugin_on_event_is_noop() {
616        let mut r = GithubReporter::new(GithubConfig::default());
617        let result = r.on_event(&TestEvent::Warning {
618            message: "test".into(),
619        });
620        assert!(result.is_ok());
621        assert!(r.output().is_empty());
622    }
623
624    #[test]
625    fn github_plugin_shutdown() {
626        let mut r = GithubReporter::new(GithubConfig::default());
627        assert!(r.shutdown().is_ok());
628    }
629}