Skip to main content

mcp_tester/
report.rs

1use chrono::{DateTime, Utc};
2use clap::ValueEnum;
3use colored::*;
4use prettytable::{row, Table};
5use serde::{Deserialize, Serialize};
6use std::io::Write;
7use std::time::Duration;
8
9/// Expand `[guide:SLUG]` tokens in `details` strings to absolute URLs into
10/// `src/server/mcp_apps/GUIDE.md`. Unknown slugs are left in place.
11///
12/// Used by the pretty printer to turn validator-emitted anchor tokens into
13/// clickable GitHub URLs at print time. The slug list mirrors the Phase 78
14/// anchor-token contract — each entry corresponds to an HTML id anchor in
15/// `src/server/mcp_apps/GUIDE.md`.
16///
17/// Per Phase 78 Plan 04 acceptance criteria, this function is `pub` so
18/// integration tests can call it directly.
19pub fn expand_guide_anchor(details: &str) -> String {
20    const KNOWN_SLUGS: &[&str] = &[
21        "handlers-before-connect",
22        "do-not-pass-tools",
23        "csp-external-resources",
24        "vite-singlefile",
25        "common-failures-claude",
26    ];
27    const URL_PREFIX: &str =
28        "https://github.com/paiml/rust-mcp-sdk/blob/main/src/server/mcp_apps/GUIDE.md#";
29    let mut out = details.to_string();
30    for slug in KNOWN_SLUGS {
31        let token = format!("[guide:{slug}]");
32        let url = format!("{URL_PREFIX}{slug}");
33        out = out.replace(&token, &url);
34    }
35    out
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, ValueEnum, Serialize, Deserialize)]
39pub enum OutputFormat {
40    Pretty,
41    Json,
42    Minimal,
43    Verbose,
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub enum TestStatus {
48    Passed,
49    Failed,
50    Warning,
51    Skipped,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub enum TestCategory {
56    Core,
57    /// HTTP transport-surface conformance (GET/OPTIONS/DELETE on the MCP endpoint).
58    ///
59    /// Distinct from the JSON-RPC-over-POST `Core` domain: catches Streamable-HTTP
60    /// misconfigurations a POST-only suite cannot see (e.g. `GET /mcp` rewritten
61    /// to a JSON health endpoint by a reverse proxy or edge function).
62    Transport,
63    Protocol,
64    Tools,
65    Resources,
66    Prompts,
67    Performance,
68    Compatibility,
69    Apps,
70    Tasks,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct TestResult {
75    pub name: String,
76    pub category: TestCategory,
77    pub status: TestStatus,
78    pub duration: Duration,
79    pub error: Option<String>,
80    pub details: Option<String>,
81}
82
83impl TestResult {
84    /// Create a passing test result.
85    pub fn passed(
86        name: impl Into<String>,
87        category: TestCategory,
88        duration: Duration,
89        details: impl Into<String>,
90    ) -> Self {
91        Self {
92            name: name.into(),
93            category,
94            status: TestStatus::Passed,
95            duration,
96            error: None,
97            details: Some(details.into()),
98        }
99    }
100
101    /// Create a failing test result.
102    pub fn failed(
103        name: impl Into<String>,
104        category: TestCategory,
105        duration: Duration,
106        error: impl Into<String>,
107    ) -> Self {
108        Self {
109            name: name.into(),
110            category,
111            status: TestStatus::Failed,
112            duration,
113            error: Some(error.into()),
114            details: None,
115        }
116    }
117
118    /// Create a warning test result.
119    pub fn warning(
120        name: impl Into<String>,
121        category: TestCategory,
122        duration: Duration,
123        details: impl Into<String>,
124    ) -> Self {
125        Self {
126            name: name.into(),
127            category,
128            status: TestStatus::Warning,
129            duration,
130            error: None,
131            details: Some(details.into()),
132        }
133    }
134
135    /// Create a skipped test result.
136    pub fn skipped(
137        name: impl Into<String>,
138        category: TestCategory,
139        details: impl Into<String>,
140    ) -> Self {
141        Self {
142            name: name.into(),
143            category,
144            status: TestStatus::Skipped,
145            duration: Duration::from_secs(0),
146            error: None,
147            details: Some(details.into()),
148        }
149    }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct TestReport {
154    pub tests: Vec<TestResult>,
155    pub duration: Duration,
156    pub timestamp: DateTime<Utc>,
157    pub summary: TestSummary,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct TestSummary {
162    pub total: usize,
163    pub passed: usize,
164    pub failed: usize,
165    pub warnings: usize,
166    pub skipped: usize,
167}
168
169impl Default for TestReport {
170    fn default() -> Self {
171        Self {
172            tests: Vec::new(),
173            duration: Duration::from_secs(0),
174            timestamp: Utc::now(),
175            summary: TestSummary {
176                total: 0,
177                passed: 0,
178                failed: 0,
179                warnings: 0,
180                skipped: 0,
181            },
182        }
183    }
184}
185
186impl TestReport {
187    pub fn new() -> Self {
188        Self::default()
189    }
190
191    pub fn from_error(error: anyhow::Error) -> Self {
192        let mut report = Self::new();
193        report.add_test(TestResult {
194            name: "Error".to_string(),
195            category: TestCategory::Core,
196            status: TestStatus::Failed,
197            duration: Duration::from_secs(0),
198            error: Some(error.to_string()),
199            details: None,
200        });
201        report
202    }
203
204    pub fn add_test(&mut self, test: TestResult) {
205        match test.status {
206            TestStatus::Passed => self.summary.passed += 1,
207            TestStatus::Failed => self.summary.failed += 1,
208            TestStatus::Warning => self.summary.warnings += 1,
209            TestStatus::Skipped => self.summary.skipped += 1,
210        }
211        self.summary.total += 1;
212        self.tests.push(test);
213    }
214
215    pub fn has_failures(&self) -> bool {
216        self.summary.failed > 0
217    }
218
219    pub fn apply_strict_mode(&mut self) {
220        // In strict mode, warnings become failures
221        for test in &mut self.tests {
222            if test.status == TestStatus::Warning {
223                test.status = TestStatus::Failed;
224                self.summary.warnings -= 1;
225                self.summary.failed += 1;
226            }
227        }
228    }
229
230    pub fn print(&self, format: OutputFormat) {
231        let mut stdout = std::io::stdout();
232        // Best-effort write to stdout: ignore I/O errors here because the CLI
233        // entry point cannot do anything meaningful with a broken-pipe error
234        // at the report layer. Tests use `print_to_writer` to capture output.
235        let _ = self.print_to_writer(format, &mut stdout);
236    }
237
238    /// Writer-seam helper: render the report into any `std::io::Write` sink.
239    ///
240    /// Phase 78 Plan 04 (Codex MEDIUM): the existing `print` path wrote
241    /// directly to stdout via `println!`, which made it impossible for tests
242    /// to assert the printed bytes. This helper accepts any writer so tests
243    /// can capture into `Vec<u8>` and assert on the content.
244    pub fn print_to_writer<W: Write>(
245        &self,
246        format: OutputFormat,
247        w: &mut W,
248    ) -> std::io::Result<()> {
249        match format {
250            OutputFormat::Pretty => self.print_pretty(w),
251            OutputFormat::Json => self.print_json(w),
252            OutputFormat::Minimal => self.print_minimal(w),
253            OutputFormat::Verbose => self.print_verbose(w),
254        }
255    }
256
257    fn print_pretty<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
258        writeln!(w)?;
259        writeln!(w, "{}", "TEST RESULTS".cyan().bold())?;
260        writeln!(w, "{}", "═".repeat(60).cyan())?;
261        writeln!(w)?;
262
263        // Group tests by category
264        let mut by_category: std::collections::HashMap<String, Vec<&TestResult>> =
265            std::collections::HashMap::new();
266
267        for test in &self.tests {
268            let category = format!("{:?}", test.category);
269            by_category.entry(category).or_default().push(test);
270        }
271
272        // Print each category
273        for (category, tests) in by_category {
274            writeln!(w, "{}", format!("{}:", category).yellow().bold())?;
275            writeln!(w)?;
276
277            for test in tests {
278                self.print_test_result_pretty(w, test)?;
279            }
280            writeln!(w)?;
281        }
282
283        // Print summary
284        self.print_summary_pretty(w)?;
285
286        // Print recommendations if there are failures
287        if self.has_failures() {
288            self.print_recommendations(w)?;
289        }
290        Ok(())
291    }
292
293    fn print_test_result_pretty<W: Write>(
294        &self,
295        w: &mut W,
296        test: &TestResult,
297    ) -> std::io::Result<()> {
298        let status_symbol = match test.status {
299            TestStatus::Passed => "✓".green().bold(),
300            TestStatus::Failed => "✗".red().bold(),
301            TestStatus::Warning => "⚠".yellow().bold(),
302            TestStatus::Skipped => "○".dimmed(),
303        };
304
305        let name = if test.name.len() > 40 {
306            format!("{}...", &test.name[..37])
307        } else {
308            test.name.clone()
309        };
310
311        write!(w, "  {} {:<40}", status_symbol, name)?;
312
313        // Print duration if significant
314        if test.duration.as_millis() > 100 {
315            write!(w, " {:>6}ms", test.duration.as_millis())?;
316        } else {
317            write!(w, "         ")?;
318        }
319
320        // Print details or error. Phase 78 Plan 04: expand `[guide:SLUG]`
321        // tokens in `details` strings to absolute GUIDE.md URLs at print
322        // time so error messages link to actionable documentation.
323        if let Some(error) = &test.error {
324            writeln!(w, " {}", error.red())?;
325        } else if let Some(details) = &test.details {
326            let expanded = expand_guide_anchor(details);
327            if test.status == TestStatus::Warning {
328                writeln!(w, " {}", expanded.yellow())?;
329            } else {
330                writeln!(w, " {}", expanded.dimmed())?;
331            }
332        } else {
333            writeln!(w)?;
334        }
335        Ok(())
336    }
337
338    fn print_summary_pretty<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
339        writeln!(w, "{}", "═".repeat(60).cyan())?;
340        writeln!(w, "{}", "SUMMARY".cyan().bold())?;
341        writeln!(w, "{}", "═".repeat(60).cyan())?;
342        writeln!(w)?;
343
344        let mut table = Table::new();
345        table.add_row(row!["Total Tests", self.summary.total.to_string().bold()]);
346        table.add_row(row![
347            "Passed",
348            self.summary.passed.to_string().green().bold()
349        ]);
350
351        if self.summary.failed > 0 {
352            table.add_row(row!["Failed", self.summary.failed.to_string().red().bold()]);
353        }
354
355        if self.summary.warnings > 0 {
356            table.add_row(row![
357                "Warnings",
358                self.summary.warnings.to_string().yellow().bold()
359            ]);
360        }
361
362        if self.summary.skipped > 0 {
363            table.add_row(row!["Skipped", self.summary.skipped.to_string().dimmed()]);
364        }
365
366        table.add_row(row![
367            "Duration",
368            format!("{:.2}s", self.duration.as_secs_f64())
369        ]);
370
371        table.print(w)?;
372        writeln!(w)?;
373
374        // Overall status
375        let overall = if self.summary.failed > 0 {
376            "FAILED".red().bold()
377        } else if self.summary.warnings > 0 {
378            "PASSED WITH WARNINGS".yellow().bold()
379        } else {
380            "PASSED".green().bold()
381        };
382
383        writeln!(w, "Overall Status: {}", overall)?;
384        Ok(())
385    }
386
387    fn print_recommendations<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
388        writeln!(w)?;
389        writeln!(w, "{}", "RECOMMENDATIONS".yellow().bold())?;
390        writeln!(w, "{}", "═".repeat(60).yellow())?;
391        writeln!(w)?;
392
393        let failed_tests: Vec<_> = self
394            .tests
395            .iter()
396            .filter(|t| t.status == TestStatus::Failed)
397            .collect();
398
399        if failed_tests.is_empty() {
400            return Ok(());
401        }
402
403        // Group failures by category
404        let mut protocol_failures = 0;
405        let mut tool_failures = 0;
406        let mut core_failures = 0;
407        let mut task_failures = 0;
408
409        for test in &failed_tests {
410            match test.category {
411                TestCategory::Protocol => protocol_failures += 1,
412                TestCategory::Tools => tool_failures += 1,
413                TestCategory::Core => core_failures += 1,
414                TestCategory::Tasks => task_failures += 1,
415                _ => {},
416            }
417        }
418
419        if core_failures > 0 {
420            writeln!(w, "  • Fix core connectivity issues first")?;
421            writeln!(w, "    - Verify server is running and accessible")?;
422            writeln!(w, "    - Check network configuration and firewall rules")?;
423        }
424
425        if protocol_failures > 0 {
426            writeln!(w, "  • Review MCP protocol implementation")?;
427            writeln!(w, "    - Ensure JSON-RPC 2.0 compliance")?;
428            writeln!(w, "    - Verify protocol version compatibility")?;
429            writeln!(w, "    - Check required method implementations")?;
430        }
431
432        if tool_failures > 0 {
433            writeln!(w, "  • Debug tool implementations")?;
434            writeln!(w, "    - Verify tool registration and handlers")?;
435            writeln!(w, "    - Check input validation and error handling")?;
436            writeln!(w, "    - Review tool response formats")?;
437        }
438
439        if task_failures > 0 {
440            writeln!(w, "  - Debug task implementations")?;
441            writeln!(
442                w,
443                "    - Verify task capability is advertised in ServerCapabilities"
444            )?;
445            writeln!(
446                w,
447                "    - Check task lifecycle state machine (working -> completed/failed)"
448            )?;
449            writeln!(
450                w,
451                "    - Ensure tasks/get and tasks/list return valid Task structures"
452            )?;
453        }
454
455        writeln!(w)?;
456        writeln!(w, "Run with --verbose for detailed error information")?;
457        Ok(())
458    }
459
460    fn print_json<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
461        let json = serde_json::to_string_pretty(self).unwrap();
462        writeln!(w, "{}", json)
463    }
464
465    fn print_minimal<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
466        let status = if self.summary.failed > 0 {
467            "FAIL"
468        } else {
469            "PASS"
470        };
471
472        writeln!(
473            w,
474            "{}: {} passed, {} failed, {} warnings in {:.2}s",
475            status,
476            self.summary.passed,
477            self.summary.failed,
478            self.summary.warnings,
479            self.duration.as_secs_f64()
480        )
481    }
482
483    fn print_verbose<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
484        self.print_pretty(w)?;
485
486        writeln!(w)?;
487        writeln!(w, "{}", "DETAILED TEST INFORMATION".cyan().bold())?;
488        writeln!(w, "{}", "═".repeat(60).cyan())?;
489        writeln!(w)?;
490
491        for test in &self.tests {
492            writeln!(w, "Test: {}", test.name.bold())?;
493            writeln!(w, "  Category: {:?}", test.category)?;
494            writeln!(w, "  Status: {:?}", test.status)?;
495            writeln!(w, "  Duration: {:?}", test.duration)?;
496
497            if let Some(error) = &test.error {
498                writeln!(w, "  Error: {}", error.red())?;
499            }
500
501            if let Some(details) = &test.details {
502                // Phase 78 Plan 04: also expand in verbose mode for consistency.
503                let expanded = expand_guide_anchor(details);
504                writeln!(w, "  Details: {}", expanded)?;
505            }
506
507            writeln!(w)?;
508        }
509        Ok(())
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    /// Transport variant must exist and round-trip through serde JSON unchanged.
518    #[test]
519    fn transport_category_serde_roundtrip() {
520        let original = TestCategory::Transport;
521        let json = serde_json::to_string(&original).expect("serialize");
522        assert_eq!(json, "\"Transport\"");
523        let parsed: TestCategory = serde_json::from_str(&json).expect("deserialize");
524        assert_eq!(parsed, TestCategory::Transport);
525    }
526
527    /// Transport variant participates in equality / clone / debug like its siblings.
528    #[test]
529    fn transport_category_traits() {
530        let a = TestCategory::Transport;
531        let b = a.clone();
532        assert_eq!(a, b);
533        assert_ne!(a, TestCategory::Core);
534        // Debug must produce the variant name (used by print_pretty grouping).
535        assert_eq!(format!("{:?}", a), "Transport");
536    }
537
538    /// A TestResult tagged Transport must aggregate correctly in the summary counters.
539    #[test]
540    fn transport_results_aggregate_in_summary() {
541        let mut report = TestReport::new();
542        report.add_test(TestResult::passed(
543            "Transport: GET /mcp",
544            TestCategory::Transport,
545            Duration::from_millis(10),
546            "ok",
547        ));
548        report.add_test(TestResult::failed(
549            "Transport: OPTIONS /mcp",
550            TestCategory::Transport,
551            Duration::from_millis(10),
552            "boom",
553        ));
554        report.add_test(TestResult::warning(
555            "Transport: DELETE /mcp",
556            TestCategory::Transport,
557            Duration::from_millis(10),
558            "warn",
559        ));
560        assert_eq!(report.summary.total, 3);
561        assert_eq!(report.summary.passed, 1);
562        assert_eq!(report.summary.failed, 1);
563        assert_eq!(report.summary.warnings, 1);
564        assert!(report.has_failures());
565    }
566
567    // -----------------------------------------------------------------------
568    // Phase 78 Plan 04 Task 1: expand_guide_anchor unit tests
569    // -----------------------------------------------------------------------
570
571    /// Known slug `handlers-before-connect` is replaced with the canonical URL.
572    #[test]
573    fn expand_guide_anchor_handlers_before_connect() {
574        let out = expand_guide_anchor("Missing handler [guide:handlers-before-connect]");
575        assert_eq!(
576            out,
577            "Missing handler https://github.com/paiml/rust-mcp-sdk/blob/main/src/server/mcp_apps/GUIDE.md#handlers-before-connect"
578        );
579    }
580
581    /// A string with no token round-trips unchanged.
582    #[test]
583    fn expand_guide_anchor_no_token() {
584        let out = expand_guide_anchor("plain text");
585        assert_eq!(out, "plain text");
586    }
587
588    /// Unknown slugs are left in place (NOT silently dropped).
589    #[test]
590    fn expand_guide_anchor_unknown_slug() {
591        let input = "see [guide:not-a-real-slug] for details";
592        let out = expand_guide_anchor(input);
593        assert_eq!(out, input, "unknown slugs must be left in place");
594    }
595
596    /// Both known tokens in a single string are fully expanded.
597    #[test]
598    fn expand_guide_anchor_multiple_tokens() {
599        let input = "First [guide:handlers-before-connect], then [guide:common-failures-claude].";
600        let out = expand_guide_anchor(input);
601        assert!(
602            out.contains(
603                "https://github.com/paiml/rust-mcp-sdk/blob/main/src/server/mcp_apps/GUIDE.md#handlers-before-connect"
604            ),
605            "first token must expand; got: {}",
606            out
607        );
608        assert!(
609            out.contains(
610                "https://github.com/paiml/rust-mcp-sdk/blob/main/src/server/mcp_apps/GUIDE.md#common-failures-claude"
611            ),
612            "second token must expand; got: {}",
613            out
614        );
615        assert!(
616            !out.contains("[guide:"),
617            "no [guide:...] tokens must remain; got: {}",
618            out
619        );
620    }
621
622    /// `[guide:common-failures-claude]` expands to a URL ending with `#common-failures-claude`.
623    #[test]
624    fn expand_guide_anchor_common_failures() {
625        let out = expand_guide_anchor("[guide:common-failures-claude]");
626        assert!(
627            out.ends_with("#common-failures-claude"),
628            "expected URL ending with #common-failures-claude; got: {}",
629            out
630        );
631        assert!(
632            out.starts_with("https://"),
633            "expected absolute URL; got: {}",
634            out
635        );
636    }
637
638    /// REVISION (Codex MEDIUM): the printer wiring itself must produce the
639    /// expanded URL, not just the helper. Build a TestReport, render via
640    /// `print_to_writer` to a Vec<u8>, then assert on the captured bytes.
641    #[test]
642    fn pretty_output_includes_expanded_url() {
643        // colored output adds ANSI escape sequences which may interfere with
644        // substring matching. Disable colorization for this test.
645        colored::control::set_override(false);
646
647        let mut report = TestReport::new();
648        report.add_test(TestResult {
649            name: "[example] handler: onteardown".to_string(),
650            category: TestCategory::Apps,
651            status: TestStatus::Failed,
652            duration: Duration::from_secs(0),
653            error: None,
654            details: Some(
655                "Widget does not register onteardown. [guide:handlers-before-connect]".to_string(),
656            ),
657        });
658        let mut buf: Vec<u8> = Vec::new();
659        report
660            .print_to_writer(OutputFormat::Pretty, &mut buf)
661            .expect("write to Vec<u8> should not fail");
662        let captured = String::from_utf8_lossy(&buf);
663        assert!(
664            captured.contains(
665                "https://github.com/paiml/rust-mcp-sdk/blob/main/src/server/mcp_apps/GUIDE.md#handlers-before-connect"
666            ),
667            "pretty output must contain the expanded URL; got:\n{}",
668            captured
669        );
670        assert!(
671            !captured.contains("[guide:handlers-before-connect]"),
672            "pretty output must not contain the unexpanded token; got:\n{}",
673            captured
674        );
675
676        // Restore default colorization behavior.
677        colored::control::unset_override();
678    }
679}