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 let results: SuiteRunResults = match serde_json::from_str(raw) {
358 Ok(v) => v,
359 Err(_) => {
360 return format!(
361 "## API test summary\n\n- {}\n",
362 if let Some(label) = input_label {
363 format!("invalid JSON in: `{label}`")
364 } else {
365 "invalid JSON from stdin".to_string()
366 }
367 );
368 }
369 };
370
371 render_summary_markdown(&results, options)
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use crate::suite::results::{SuiteCaseResult, SuiteRunResults, SuiteRunSummary};
378
379 fn base_results(summary: SuiteRunSummary, cases: Vec<SuiteCaseResult>) -> SuiteRunResults {
380 SuiteRunResults {
381 version: 1,
382 suite: "sample".to_string(),
383 suite_file: "tests/api/suites/sample.suite.json".to_string(),
384 run_id: "run-1".to_string(),
385 started_at: "2026-02-02T00:00:00Z".to_string(),
386 finished_at: "2026-02-02T00:00:10Z".to_string(),
387 output_dir: "out/api-test-runner/run-1".to_string(),
388 summary,
389 cases,
390 }
391 }
392
393 #[test]
394 fn render_summary_markdown_handles_successful_runs() {
395 let summary = SuiteRunSummary {
396 total: 1,
397 passed: 1,
398 failed: 0,
399 skipped: 0,
400 };
401 let cases = vec![SuiteCaseResult {
402 id: "rest.health".to_string(),
403 case_type: "rest".to_string(),
404 status: "passed".to_string(),
405 duration_ms: 12,
406 tags: Vec::new(),
407 command: None,
408 message: None,
409 assertions: None,
410 stdout_file: Some("out/run-1/rest.health.response.json".to_string()),
411 stderr_file: Some("out/run-1/rest.health.stderr.log".to_string()),
412 }];
413 let results = base_results(summary, cases);
414 let markdown = render_summary_markdown(&results, &SummaryOptions::default());
415
416 assert!(markdown.contains("## API test summary: sample"));
417 assert!(markdown.contains("### Failed (0)"));
418 assert!(markdown.contains("(none)"));
419 }
420
421 #[test]
422 fn render_summary_markdown_includes_failed_cases() {
423 let summary = SuiteRunSummary {
424 total: 1,
425 passed: 0,
426 failed: 1,
427 skipped: 0,
428 };
429 let cases = vec![SuiteCaseResult {
430 id: "gql.health".to_string(),
431 case_type: "graphql".to_string(),
432 status: "failed".to_string(),
433 duration_ms: 120,
434 tags: Vec::new(),
435 command: None,
436 message: Some("boom".to_string()),
437 assertions: None,
438 stdout_file: Some("out/run-1/gql.health.response.json".to_string()),
439 stderr_file: Some("out/run-1/gql.health.stderr.log".to_string()),
440 }];
441 let results = base_results(summary, cases);
442 let markdown = render_summary_markdown(&results, &SummaryOptions::default());
443
444 assert!(markdown.contains("### Failed (1)"));
445 assert!(markdown.contains("boom"));
446 }
447
448 #[test]
449 fn summary_renders_totals_table() {
450 let results = SuiteRunResults {
451 version: 1,
452 suite: "smoke".to_string(),
453 suite_file: "tests/api/suites/smoke.suite.json".to_string(),
454 run_id: "20260131-000000Z".to_string(),
455 started_at: "2026-01-31T00:00:00Z".to_string(),
456 finished_at: "2026-01-31T00:00:01Z".to_string(),
457 output_dir: "out/api-test-runner/20260131-000000Z".to_string(),
458 summary: crate::suite::results::SuiteRunSummary {
459 total: 3,
460 passed: 2,
461 failed: 1,
462 skipped: 0,
463 },
464 cases: vec![],
465 };
466
467 let md = render_summary_markdown(&results, &SummaryOptions::default());
468 assert!(md.contains("### Totals"));
469 assert!(md.contains("| total | passed | failed | skipped |"));
470 }
471
472 #[test]
473 fn summary_renders_failed_skipped_and_slowest_with_limits() {
474 let results = SuiteRunResults {
475 version: 1,
476 suite: "smoke".to_string(),
477 suite_file: "tests/api/suites/smoke.suite.json".to_string(),
478 run_id: "run`id & <tag>".to_string(),
479 started_at: "2026-01-31T00:00:00Z".to_string(),
480 finished_at: "2026-01-31T00:00:05Z".to_string(),
481 output_dir: "out/api-test-runner/20260131-000000Z".to_string(),
482 summary: crate::suite::results::SuiteRunSummary {
483 total: 6,
484 passed: 1,
485 failed: 3,
486 skipped: 2,
487 },
488 cases: vec![
489 crate::suite::results::SuiteCaseResult {
490 id: "fail.1".to_string(),
491 case_type: "rest".to_string(),
492 status: "failed".to_string(),
493 duration_ms: 50,
494 tags: vec![],
495 command: None,
496 message: Some("bad | pipe".to_string()),
497 assertions: None,
498 stdout_file: Some("out/stdout-1.txt".to_string()),
499 stderr_file: Some("out/stderr-1.txt".to_string()),
500 },
501 crate::suite::results::SuiteCaseResult {
502 id: "fail.2".to_string(),
503 case_type: "graphql".to_string(),
504 status: "failed".to_string(),
505 duration_ms: 150,
506 tags: vec![],
507 command: None,
508 message: Some("write_cases_disabled".to_string()),
509 assertions: None,
510 stdout_file: None,
511 stderr_file: None,
512 },
513 crate::suite::results::SuiteCaseResult {
514 id: "fail.3".to_string(),
515 case_type: "rest".to_string(),
516 status: "failed".to_string(),
517 duration_ms: 20,
518 tags: vec![],
519 command: None,
520 message: Some("skipped_by_id".to_string()),
521 assertions: None,
522 stdout_file: None,
523 stderr_file: None,
524 },
525 crate::suite::results::SuiteCaseResult {
526 id: "skip.1".to_string(),
527 case_type: "rest".to_string(),
528 status: "skipped".to_string(),
529 duration_ms: 5,
530 tags: vec![],
531 command: None,
532 message: Some("write_cases_disabled".to_string()),
533 assertions: None,
534 stdout_file: None,
535 stderr_file: None,
536 },
537 crate::suite::results::SuiteCaseResult {
538 id: "skip.2".to_string(),
539 case_type: "graphql".to_string(),
540 status: "skipped".to_string(),
541 duration_ms: 7,
542 tags: vec![],
543 command: None,
544 message: Some("not_selected".to_string()),
545 assertions: None,
546 stdout_file: None,
547 stderr_file: None,
548 },
549 crate::suite::results::SuiteCaseResult {
550 id: "pass.1".to_string(),
551 case_type: "rest".to_string(),
552 status: "passed".to_string(),
553 duration_ms: 10,
554 tags: vec![],
555 command: None,
556 message: None,
557 assertions: None,
558 stdout_file: None,
559 stderr_file: None,
560 },
561 ],
562 };
563
564 let options = SummaryOptions {
565 max_failed: 1,
566 max_skipped: 1,
567 slow_n: 1,
568 ..SummaryOptions::default()
569 };
570
571 let md = render_summary_markdown(&results, &options);
572 assert!(md.contains("### Failed (3)"));
573 assert!(md.contains("…and 2 more failed cases"));
574 assert!(md.contains("### Skipped (2)"));
575 assert!(md.contains("…and 1 more skipped cases"));
576 assert!(md.contains("### Slowest (Top 1)"));
577 assert!(md.contains("<code>run`id & <tag></code>"));
578 assert!(md.contains("bad \\| pipe"));
579 }
580
581 #[test]
582 fn summary_render_handles_empty_and_invalid_json_input() {
583 let empty =
584 render_summary_from_json_str("", Some("missing.json"), &SummaryOptions::default());
585 assert!(empty.contains("results file not found or empty"));
586
587 let invalid = render_summary_from_json_str("{not-json", None, &SummaryOptions::default());
588 assert!(invalid.contains("invalid JSON from stdin"));
589 }
590}