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    // Accept both the raw `SuiteRunResults` shape (pre-Sprint-3.2 callers) and
358    // the new `cli.api-test.run.v1` envelope-wrapped shape. Try envelope first
359    // so post-migration callers parse cleanly without a fallback fail.
360    #[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 &amp; &lt;tag&gt;</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}