Skip to main content

api_testing_core/suite/
summary.rs

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('&', "&amp;")
35        .replace('<', "&lt;")
36        .replace('>', "&gt;")
37        .replace('"', "&quot;")
38        .replace('\'', "&#39;")
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 &amp; &lt;tag&gt;</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}