1use std::collections::BTreeMap;
2
3use crate::suite::results::SuiteRunResults;
4
5#[derive(Debug, Clone)]
6pub struct SummaryOptions {
7 pub slow_n: usize,
8 pub hide_skipped: bool,
9 pub max_failed: usize,
10 pub max_skipped: usize,
11}
12
13impl Default for SummaryOptions {
14 fn default() -> Self {
15 Self {
16 slow_n: 5,
17 hide_skipped: false,
18 max_failed: 50,
19 max_skipped: 50,
20 }
21 }
22}
23
24fn sanitize_one_line(value: &str) -> String {
25 value.split_whitespace().collect::<Vec<_>>().join(" ")
26}
27
28fn md_escape_cell(value: &str) -> String {
29 sanitize_one_line(value).replace('|', "\\|")
30}
31
32fn html_escape(value: &str) -> String {
33 value
34 .replace('&', "&")
35 .replace('<', "<")
36 .replace('>', ">")
37 .replace('"', """)
38 .replace('\'', "'")
39}
40
41fn md_code(value: &str) -> String {
42 let s = md_escape_cell(value);
43 if s.is_empty() {
44 return String::new();
45 }
46 if !s.contains('`') {
47 return format!("`{s}`");
48 }
49 format!("<code>{}</code>", html_escape(&s))
50}
51
52fn md_table(headers: &[&str], rows: &[Vec<String>]) -> String {
53 let mut out = String::new();
54 out.push_str("| ");
55 out.push_str(&headers.join(" | "));
56 out.push_str(" |\n| ");
57 out.push_str(&vec!["---"; headers.len()].join(" | "));
58 out.push_str(" |\n");
59 for row in rows {
60 let mut padded = row.clone();
61 while padded.len() < headers.len() {
62 padded.push(String::new());
63 }
64 out.push_str("| ");
65 out.push_str(&padded[..headers.len()].join(" | "));
66 out.push_str(" |\n");
67 }
68 out
69}
70
71fn dur_ms(case: &crate::suite::results::SuiteCaseResult) -> u64 {
72 case.duration_ms
73}
74
75pub fn render_summary_markdown(results: &SuiteRunResults, options: &SummaryOptions) -> String {
76 let mut out = String::new();
77 let suite = if results.suite.trim().is_empty() {
78 "suite"
79 } else {
80 results.suite.trim()
81 };
82
83 let failed_cases: Vec<&crate::suite::results::SuiteCaseResult> = results
84 .cases
85 .iter()
86 .filter(|c| c.status == "failed")
87 .collect();
88 let skipped_cases: Vec<&crate::suite::results::SuiteCaseResult> = results
89 .cases
90 .iter()
91 .filter(|c| c.status == "skipped")
92 .collect();
93 let executed_cases: Vec<&crate::suite::results::SuiteCaseResult> = results
94 .cases
95 .iter()
96 .filter(|c| c.status == "passed" || c.status == "failed")
97 .collect();
98
99 let mut slow_cases: Vec<&crate::suite::results::SuiteCaseResult> = executed_cases.clone();
100 slow_cases.sort_by_key(|c| std::cmp::Reverse(dur_ms(c)));
101 if options.slow_n > 0 && slow_cases.len() > options.slow_n {
102 slow_cases.truncate(options.slow_n);
103 }
104
105 out.push_str(&format!("## API test summary: {suite}\n\n"));
106
107 out.push_str("### Totals\n");
108 out.push_str(&md_table(
109 &["total", "passed", "failed", "skipped"],
110 &[vec![
111 results.summary.total.to_string(),
112 results.summary.passed.to_string(),
113 results.summary.failed.to_string(),
114 results.summary.skipped.to_string(),
115 ]],
116 ));
117 out.push('\n');
118
119 out.push_str("### Run info\n");
120 let mut info_rows: Vec<Vec<String>> = Vec::new();
121 if !results.run_id.trim().is_empty() {
122 info_rows.push(vec!["runId".to_string(), md_code(&results.run_id)]);
123 }
124 if !results.started_at.trim().is_empty() {
125 info_rows.push(vec!["startedAt".to_string(), md_code(&results.started_at)]);
126 }
127 if !results.finished_at.trim().is_empty() {
128 info_rows.push(vec![
129 "finishedAt".to_string(),
130 md_code(&results.finished_at),
131 ]);
132 }
133 if !results.suite_file.trim().is_empty() {
134 info_rows.push(vec!["suiteFile".to_string(), md_code(&results.suite_file)]);
135 }
136 if !results.output_dir.trim().is_empty() {
137 info_rows.push(vec!["outputDir".to_string(), md_code(&results.output_dir)]);
138 }
139 if info_rows.is_empty() {
140 info_rows.push(vec!["(none)".to_string(), String::new()]);
141 }
142 out.push_str(&md_table(&["field", "value"], &info_rows));
143 out.push('\n');
144
145 let case_row_full = |c: &crate::suite::results::SuiteCaseResult| -> Vec<String> {
146 vec![
147 md_code(&c.id),
148 md_escape_cell(&c.case_type),
149 md_escape_cell(&c.status),
150 dur_ms(c).to_string(),
151 md_escape_cell(c.message.as_deref().unwrap_or("")),
152 md_code(c.stdout_file.as_deref().unwrap_or("")),
153 md_code(c.stderr_file.as_deref().unwrap_or("")),
154 ]
155 };
156
157 out.push_str(&format!("### Failed ({})\n", failed_cases.len()));
158 if failed_cases.is_empty() {
159 out.push_str(&md_table(
160 &[
161 "id",
162 "type",
163 "status",
164 "durationMs",
165 "message",
166 "stdout",
167 "stderr",
168 ],
169 &[vec!["(none)".to_string()]],
170 ));
171 } else {
172 let shown: Vec<&crate::suite::results::SuiteCaseResult> = if options.max_failed > 0 {
173 failed_cases
174 .iter()
175 .take(options.max_failed)
176 .copied()
177 .collect()
178 } else {
179 failed_cases.clone()
180 };
181 let rows = shown.into_iter().map(case_row_full).collect::<Vec<_>>();
182 out.push_str(&md_table(
183 &[
184 "id",
185 "type",
186 "status",
187 "durationMs",
188 "message",
189 "stdout",
190 "stderr",
191 ],
192 &rows,
193 ));
194 if options.max_failed > 0 && failed_cases.len() > options.max_failed {
195 out.push_str(&format!(
196 "\n_…and {} more failed cases_\n",
197 failed_cases.len() - options.max_failed
198 ));
199 }
200 }
201 out.push('\n');
202
203 out.push_str(&format!("### Slowest (Top {})\n", options.slow_n));
204 if slow_cases.is_empty() {
205 out.push_str(&md_table(
206 &[
207 "id",
208 "type",
209 "status",
210 "durationMs",
211 "message",
212 "stdout",
213 "stderr",
214 ],
215 &[vec!["(none)".to_string()]],
216 ));
217 } else {
218 let rows = slow_cases
219 .into_iter()
220 .map(case_row_full)
221 .collect::<Vec<_>>();
222 out.push_str(&md_table(
223 &[
224 "id",
225 "type",
226 "status",
227 "durationMs",
228 "message",
229 "stdout",
230 "stderr",
231 ],
232 &rows,
233 ));
234 }
235 out.push('\n');
236
237 if !options.hide_skipped {
238 out.push_str(&format!("### Skipped ({})\n", skipped_cases.len()));
239 if skipped_cases.is_empty() {
240 out.push_str(&md_table(
241 &["id", "type", "message"],
242 &[vec!["(none)".to_string()]],
243 ));
244 } else {
245 let mut reasons: BTreeMap<String, u32> = BTreeMap::new();
246 for c in &skipped_cases {
247 let r = sanitize_one_line(c.message.as_deref().unwrap_or(""));
248 let r = if r.is_empty() {
249 "(none)".to_string()
250 } else {
251 r
252 };
253 *reasons.entry(r).or_default() += 1;
254 }
255
256 let hint_for = |reason: &str| -> &'static str {
257 match reason {
258 "write_cases_disabled" => {
259 "Enable writes with API_TEST_ALLOW_WRITES_ENABLED=true (or --allow-writes) to run allowWrite cases."
260 }
261 "not_selected" => "Case not selected (check --only filter).",
262 "skipped_by_id" => "Case skipped by id (check --skip filter).",
263 "tag_mismatch" => "Case tags did not match selected --tag filters.",
264 _ => "",
265 }
266 };
267
268 let mut reason_rows: Vec<Vec<String>> = Vec::new();
269 for (reason, count) in reasons {
270 reason_rows.push(vec![
271 md_code(&reason),
272 count.to_string(),
273 md_escape_cell(hint_for(&reason)),
274 ]);
275 }
276 out.push_str(&md_table(&["reason", "count", "hint"], &reason_rows));
277 out.push('\n');
278
279 out.push_str(&format!(
280 "#### Cases ({})\n",
281 if options.max_skipped > 0 {
282 format!("max {}", options.max_skipped)
283 } else {
284 "all".to_string()
285 }
286 ));
287 let shown: Vec<&crate::suite::results::SuiteCaseResult> = if options.max_skipped > 0 {
288 skipped_cases
289 .iter()
290 .take(options.max_skipped)
291 .copied()
292 .collect()
293 } else {
294 skipped_cases.clone()
295 };
296 let rows = shown
297 .into_iter()
298 .map(|c| {
299 vec![
300 md_code(&c.id),
301 md_escape_cell(&c.case_type),
302 md_escape_cell(c.message.as_deref().unwrap_or("")),
303 ]
304 })
305 .collect::<Vec<_>>();
306 out.push_str(&md_table(&["id", "type", "message"], &rows));
307 if options.max_skipped > 0 && skipped_cases.len() > options.max_skipped {
308 out.push_str(&format!(
309 "\n_…and {} more skipped cases_\n",
310 skipped_cases.len() - options.max_skipped
311 ));
312 }
313 }
314 out.push('\n');
315 }
316
317 out.push_str(&format!("### Executed cases ({})\n", executed_cases.len()));
318 if executed_cases.is_empty() {
319 out.push_str(&md_table(
320 &["id", "status", "durationMs"],
321 &[vec!["(none)".to_string()]],
322 ));
323 } else {
324 let rows = executed_cases
325 .into_iter()
326 .map(|c| {
327 vec![
328 md_code(&c.id),
329 md_escape_cell(&c.status),
330 dur_ms(c).to_string(),
331 ]
332 })
333 .collect::<Vec<_>>();
334 out.push_str(&md_table(&["id", "status", "durationMs"], &rows));
335 }
336
337 out
338}
339
340pub fn render_summary_from_json_str(
341 raw: &str,
342 input_label: Option<&str>,
343 options: &SummaryOptions,
344) -> String {
345 let raw = raw.trim();
346 if raw.is_empty() {
347 return format!(
348 "## API test summary\n\n- {}\n",
349 if let Some(label) = input_label {
350 format!("results file not found or empty: `{label}`")
351 } else {
352 "no input provided (stdin is empty)".to_string()
353 }
354 );
355 }
356
357 #[derive(serde::Deserialize)]
361 struct EnvelopeShape {
362 data: Option<SuiteRunResults>,
363 }
364
365 let results: SuiteRunResults = match serde_json::from_str::<EnvelopeShape>(raw) {
366 Ok(envelope) if envelope.data.is_some() => envelope.data.expect("checked above"),
367 _ => match serde_json::from_str(raw) {
368 Ok(v) => v,
369 Err(_) => {
370 return format!(
371 "## API test summary\n\n- {}\n",
372 if let Some(label) = input_label {
373 format!("invalid JSON in: `{label}`")
374 } else {
375 "invalid JSON from stdin".to_string()
376 }
377 );
378 }
379 },
380 };
381
382 render_summary_markdown(&results, options)
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::suite::results::{SuiteCaseResult, SuiteRunResults, SuiteRunSummary};
389
390 fn base_results(summary: SuiteRunSummary, cases: Vec<SuiteCaseResult>) -> SuiteRunResults {
391 SuiteRunResults {
392 version: 1,
393 suite: "sample".to_string(),
394 suite_file: "tests/api/suites/sample.suite.json".to_string(),
395 run_id: "run-1".to_string(),
396 started_at: "2026-02-02T00:00:00Z".to_string(),
397 finished_at: "2026-02-02T00:00:10Z".to_string(),
398 output_dir: "out/api-test-runner/run-1".to_string(),
399 summary,
400 cases,
401 }
402 }
403
404 #[test]
405 fn render_summary_markdown_handles_successful_runs() {
406 let summary = SuiteRunSummary {
407 total: 1,
408 passed: 1,
409 failed: 0,
410 skipped: 0,
411 };
412 let cases = vec![SuiteCaseResult {
413 id: "rest.health".to_string(),
414 case_type: "rest".to_string(),
415 status: "passed".to_string(),
416 duration_ms: 12,
417 tags: Vec::new(),
418 command: None,
419 message: None,
420 assertions: None,
421 stdout_file: Some("out/run-1/rest.health.response.json".to_string()),
422 stderr_file: Some("out/run-1/rest.health.stderr.log".to_string()),
423 }];
424 let results = base_results(summary, cases);
425 let markdown = render_summary_markdown(&results, &SummaryOptions::default());
426
427 assert!(markdown.contains("## API test summary: sample"));
428 assert!(markdown.contains("### Failed (0)"));
429 assert!(markdown.contains("(none)"));
430 }
431
432 #[test]
433 fn render_summary_markdown_includes_failed_cases() {
434 let summary = SuiteRunSummary {
435 total: 1,
436 passed: 0,
437 failed: 1,
438 skipped: 0,
439 };
440 let cases = vec![SuiteCaseResult {
441 id: "gql.health".to_string(),
442 case_type: "graphql".to_string(),
443 status: "failed".to_string(),
444 duration_ms: 120,
445 tags: Vec::new(),
446 command: None,
447 message: Some("boom".to_string()),
448 assertions: None,
449 stdout_file: Some("out/run-1/gql.health.response.json".to_string()),
450 stderr_file: Some("out/run-1/gql.health.stderr.log".to_string()),
451 }];
452 let results = base_results(summary, cases);
453 let markdown = render_summary_markdown(&results, &SummaryOptions::default());
454
455 assert!(markdown.contains("### Failed (1)"));
456 assert!(markdown.contains("boom"));
457 }
458
459 #[test]
460 fn summary_renders_totals_table() {
461 let results = SuiteRunResults {
462 version: 1,
463 suite: "smoke".to_string(),
464 suite_file: "tests/api/suites/smoke.suite.json".to_string(),
465 run_id: "20260131-000000Z".to_string(),
466 started_at: "2026-01-31T00:00:00Z".to_string(),
467 finished_at: "2026-01-31T00:00:01Z".to_string(),
468 output_dir: "out/api-test-runner/20260131-000000Z".to_string(),
469 summary: crate::suite::results::SuiteRunSummary {
470 total: 3,
471 passed: 2,
472 failed: 1,
473 skipped: 0,
474 },
475 cases: vec![],
476 };
477
478 let md = render_summary_markdown(&results, &SummaryOptions::default());
479 assert!(md.contains("### Totals"));
480 assert!(md.contains("| total | passed | failed | skipped |"));
481 }
482
483 #[test]
484 fn summary_renders_failed_skipped_and_slowest_with_limits() {
485 let results = SuiteRunResults {
486 version: 1,
487 suite: "smoke".to_string(),
488 suite_file: "tests/api/suites/smoke.suite.json".to_string(),
489 run_id: "run`id & <tag>".to_string(),
490 started_at: "2026-01-31T00:00:00Z".to_string(),
491 finished_at: "2026-01-31T00:00:05Z".to_string(),
492 output_dir: "out/api-test-runner/20260131-000000Z".to_string(),
493 summary: crate::suite::results::SuiteRunSummary {
494 total: 6,
495 passed: 1,
496 failed: 3,
497 skipped: 2,
498 },
499 cases: vec![
500 crate::suite::results::SuiteCaseResult {
501 id: "fail.1".to_string(),
502 case_type: "rest".to_string(),
503 status: "failed".to_string(),
504 duration_ms: 50,
505 tags: vec![],
506 command: None,
507 message: Some("bad | pipe".to_string()),
508 assertions: None,
509 stdout_file: Some("out/stdout-1.txt".to_string()),
510 stderr_file: Some("out/stderr-1.txt".to_string()),
511 },
512 crate::suite::results::SuiteCaseResult {
513 id: "fail.2".to_string(),
514 case_type: "graphql".to_string(),
515 status: "failed".to_string(),
516 duration_ms: 150,
517 tags: vec![],
518 command: None,
519 message: Some("write_cases_disabled".to_string()),
520 assertions: None,
521 stdout_file: None,
522 stderr_file: None,
523 },
524 crate::suite::results::SuiteCaseResult {
525 id: "fail.3".to_string(),
526 case_type: "rest".to_string(),
527 status: "failed".to_string(),
528 duration_ms: 20,
529 tags: vec![],
530 command: None,
531 message: Some("skipped_by_id".to_string()),
532 assertions: None,
533 stdout_file: None,
534 stderr_file: None,
535 },
536 crate::suite::results::SuiteCaseResult {
537 id: "skip.1".to_string(),
538 case_type: "rest".to_string(),
539 status: "skipped".to_string(),
540 duration_ms: 5,
541 tags: vec![],
542 command: None,
543 message: Some("write_cases_disabled".to_string()),
544 assertions: None,
545 stdout_file: None,
546 stderr_file: None,
547 },
548 crate::suite::results::SuiteCaseResult {
549 id: "skip.2".to_string(),
550 case_type: "graphql".to_string(),
551 status: "skipped".to_string(),
552 duration_ms: 7,
553 tags: vec![],
554 command: None,
555 message: Some("not_selected".to_string()),
556 assertions: None,
557 stdout_file: None,
558 stderr_file: None,
559 },
560 crate::suite::results::SuiteCaseResult {
561 id: "pass.1".to_string(),
562 case_type: "rest".to_string(),
563 status: "passed".to_string(),
564 duration_ms: 10,
565 tags: vec![],
566 command: None,
567 message: None,
568 assertions: None,
569 stdout_file: None,
570 stderr_file: None,
571 },
572 ],
573 };
574
575 let options = SummaryOptions {
576 max_failed: 1,
577 max_skipped: 1,
578 slow_n: 1,
579 ..SummaryOptions::default()
580 };
581
582 let md = render_summary_markdown(&results, &options);
583 assert!(md.contains("### Failed (3)"));
584 assert!(md.contains("…and 2 more failed cases"));
585 assert!(md.contains("### Skipped (2)"));
586 assert!(md.contains("…and 1 more skipped cases"));
587 assert!(md.contains("### Slowest (Top 1)"));
588 assert!(md.contains("<code>run`id & <tag></code>"));
589 assert!(md.contains("bad \\| pipe"));
590 }
591
592 #[test]
593 fn summary_render_handles_empty_and_invalid_json_input() {
594 let empty =
595 render_summary_from_json_str("", Some("missing.json"), &SummaryOptions::default());
596 assert!(empty.contains("results file not found or empty"));
597
598 let invalid = render_summary_from_json_str("{not-json", None, &SummaryOptions::default());
599 assert!(invalid.contains("invalid JSON from stdin"));
600 }
601}