1use 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#[derive(Debug, Clone)]
16pub struct GithubConfig {
17 pub annotations: bool,
19 pub groups: bool,
21 pub step_summary: bool,
23 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
38pub 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 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
77pub 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 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 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 for line in md.lines() {
211 let escaped = line.replace('`', "\\`");
212 lines.push(format!("echo '{escaped}' >> $GITHUB_STEP_SUMMARY"));
213 }
214}
215
216fn parse_location(loc: &str) -> Option<(String, String)> {
218 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 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 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
236fn 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 #[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 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 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 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 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}