Skip to main content

llm_browser_testkit/
reporting.rs

1//! Report sinks: console, NDJSON, JUnit XML, GitHub annotations, Perfetto trace.
2//!
3//! The [`Reporter`] is the single place test-run output flows through. The
4//! runner emits [`TestEvent`]s; the reporter fans them out to every enabled
5//! sink:
6//!
7//! - **Console** — level-filtered, optionally colorized, ASCII-safe lines so
8//!   output renders in CI logs and dumb terminals. Failures always show full
9//!   diagnostics; debug events (LLM calls, selector resolution) are hidden
10//!   unless `-v`/`-vv` is passed.
11//! - **NDJSON** (`--log-file`) — one JSON object per event, each with a `ts`
12//!   epoch-millisecond field and a `type` discriminator. Machine-readable and
13//!   lossless (no truncation): `jq '. | select(.type == "step_finished" and
14//!   .status == "failed")' run.jsonl` works out of the box.
15//! - **JUnit XML** (`--junit`) — one `<testcase>` per test with `<failure>`
16//!   entries per failed step, for Jenkins/GitLab/Azure/TeamCity.
17//! - **GitHub** — `::error::` workflow commands on failed steps plus a
18//!   `GITHUB_STEP_SUMMARY` markdown file, when running in Actions.
19//! - **Perfetto trace** (`--trace`) — a Chrome Trace Event Format file with
20//!   test/step/LLM-call spans, openable in `ui.perfetto.dev`.
21
22use std::fs::File;
23use std::io::{self, BufWriter, IsTerminal, Write};
24use std::path::{Path, PathBuf};
25use std::sync::Mutex;
26use std::time::{Instant, SystemTime, UNIX_EPOCH};
27
28use serde_json::json;
29
30use crate::costs::UsageSnapshot;
31use crate::events::{StepStatus, TestEvent};
32
33/// Console verbosity level. Higher = more output.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
35pub enum Level {
36    /// Only failures.
37    Error = 0,
38    /// Failures, warnings, and the run summary.
39    Warn = 1,
40    /// Default: plus test and step results.
41    Info = 2,
42    /// Plus LLM call details and step starts.
43    Debug = 3,
44    /// Everything.
45    Trace = 4,
46}
47
48impl Level {
49    /// Maps `-q`/`-v` flag counts to a level. Quiet wins over verbose.
50    #[must_use]
51    pub const fn from_flags(quiet: u8, verbose: u8) -> Self {
52        match (quiet, verbose) {
53            (0, 0) => Self::Info,
54            (0, 1) => Self::Debug,
55            (0, _) => Self::Trace,
56            (1, _) => Self::Warn,
57            _ => Self::Error,
58        }
59    }
60
61    fn shows(self, level: Self) -> bool {
62        self >= level
63    }
64}
65
66#[allow(clippy::derivable_impls)]
67impl Default for Level {
68    fn default() -> Self {
69        Self::Info
70    }
71}
72
73/// Color output mode for the console sink.
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
75pub enum ColorMode {
76    /// Use colors when stderr is a TTY and `NO_COLOR` is unset.
77    #[default]
78    Auto,
79    /// Always emit ANSI colors.
80    Always,
81    /// Never emit ANSI colors.
82    Never,
83}
84
85/// Decides whether the console sink should emit ANSI codes.
86#[must_use]
87pub fn detect_color(mode: ColorMode) -> bool {
88    match mode {
89        ColorMode::Always => true,
90        ColorMode::Never => false,
91        ColorMode::Auto => std::env::var_os("NO_COLOR").is_none() && io::stderr().is_terminal(),
92    }
93}
94
95/// Minimal ANSI palette; every method degrades to plain text when disabled.
96#[derive(Clone, Copy)]
97struct Palette {
98    enabled: bool,
99}
100
101impl Palette {
102    fn paint(self, s: &str, code: &str) -> String {
103        if self.enabled {
104            format!("\x1b[{code}m{s}\x1b[0m")
105        } else {
106            s.to_owned()
107        }
108    }
109
110    fn green(self, s: &str) -> String {
111        self.paint(s, "32")
112    }
113
114    fn red(self, s: &str) -> String {
115        self.paint(s, "31")
116    }
117
118    fn yellow(self, s: &str) -> String {
119        self.paint(s, "33")
120    }
121
122    fn cyan(self, s: &str) -> String {
123        self.paint(s, "36")
124    }
125
126    fn dim(self, s: &str) -> String {
127        self.paint(s, "2")
128    }
129}
130
131/// Aggregated run counts captured from the final `RunFinished` event.
132#[derive(Clone, Copy)]
133struct RunSummary {
134    tests_passed: u32,
135    tests_failed: u32,
136    steps_passed: u32,
137    steps_failed: u32,
138    steps_skipped: u32,
139    total_cost: f64,
140    total_tokens: u64,
141    total_calls: u64,
142}
143
144/// One test's `JUnit` result, accumulated while events stream in.
145struct JunitCase {
146    name: String,
147    duration_ms: u64,
148    failures: Vec<String>,
149    skipped: u32,
150}
151
152impl JunitCase {
153    const fn new(name: String) -> Self {
154        Self {
155            name,
156            duration_ms: 0,
157            failures: Vec::new(),
158            skipped: 0,
159        }
160    }
161}
162
163/// An open (unfinished) span in the Perfetto trace.
164struct OpenSpan {
165    kind: u8,
166    name: String,
167    start: Instant,
168}
169
170/// Mutable reporter state, serialized behind a mutex so the reporter can be
171/// shared between the runner and the CLI.
172struct ReporterState {
173    jsonl: Option<BufWriter<File>>,
174    junit_path: Option<PathBuf>,
175    junit_cases: Vec<JunitCase>,
176    trace_path: Option<PathBuf>,
177    trace_events: Vec<serde_json::Value>,
178    trace_open: Vec<OpenSpan>,
179    trace_base: Option<Instant>,
180}
181
182/// Locks a mutex, recovering from a poisoned state instead of panicking.
183fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
184    mutex
185        .lock()
186        .unwrap_or_else(std::sync::PoisonError::into_inner)
187}
188
189/// Fans run events out to every enabled sink.
190pub struct Reporter {
191    level: Level,
192    palette: Palette,
193    github_annotations: bool,
194    github_summary: Mutex<Option<RunSummary>>,
195    state: Mutex<ReporterState>,
196}
197
198impl Default for Reporter {
199    fn default() -> Self {
200        Self::new(Level::Info, ColorMode::Auto, None, None, None, false)
201            .expect("default reporter has no files to open")
202    }
203}
204
205impl Reporter {
206    /// Creates a reporter with the given console settings and optional
207    /// output files. Fails if a log/JUnit/trace file cannot be opened.
208    ///
209    /// # Errors
210    ///
211    /// Returns an io error when any configured output file cannot be created.
212    #[allow(clippy::too_many_arguments)]
213    pub fn new(
214        level: Level,
215        color: ColorMode,
216        log_file: Option<&Path>,
217        junit_file: Option<&Path>,
218        trace_file: Option<&Path>,
219        github_annotations: bool,
220    ) -> io::Result<Self> {
221        let jsonl = match log_file {
222            Some(path) => Some(BufWriter::new(File::create(path)?)),
223            None => None,
224        };
225        Ok(Self {
226            level,
227            palette: Palette {
228                enabled: detect_color(color),
229            },
230            github_annotations,
231            github_summary: Mutex::new(None),
232            state: Mutex::new(ReporterState {
233                jsonl,
234                junit_path: junit_file.map(Path::to_path_buf),
235                junit_cases: Vec::new(),
236                trace_path: trace_file.map(Path::to_path_buf),
237                trace_events: Vec::new(),
238                trace_open: Vec::new(),
239                trace_base: None,
240            }),
241        })
242    }
243
244    /// Emits one event to every enabled sink.
245    ///
246    /// # Errors
247    ///
248    /// Returns an io error when writing the NDJSON log file fails.
249    pub fn emit(&self, event: &TestEvent) -> io::Result<()> {
250        let (level, text) = format_event(event, self.palette);
251        if self.level.shows(level) {
252            let _ = writeln!(io::stderr().lock(), "{text}");
253        }
254        self.write_jsonl(event)?;
255        self.track_trace(event);
256        self.track_junit(event);
257        self.track_github(event);
258        Ok(())
259    }
260
261    /// Writes a plain informational line if the current level allows it.
262    pub fn info(&self, msg: impl AsRef<str>) {
263        self.line(Level::Info, msg.as_ref());
264    }
265
266    /// Writes a debug line if `-v` (or higher) was passed.
267    pub fn debug(&self, msg: impl AsRef<str>) {
268        self.line(Level::Debug, msg.as_ref());
269    }
270
271    /// Writes a warning line; always shown unless `-qq` was passed.
272    pub fn warn(&self, msg: impl AsRef<str>) {
273        self.line(Level::Warn, &format!("  ! {}", msg.as_ref()));
274    }
275
276    /// Writes an error line; always shown.
277    pub fn error(&self, msg: impl AsRef<str>) {
278        let text = self.palette.red(&format!("  ✗ {}", msg.as_ref()));
279        self.line(Level::Error, &text);
280    }
281
282    fn line(&self, level: Level, text: &str) {
283        if self.level.shows(level) {
284            let _ = writeln!(io::stderr().lock(), "{text}");
285        }
286    }
287
288    /// Flushes the `NDJSON` log and writes `JUnit`, trace, and `GitHub`
289    /// summary files. Call once after the run finishes.
290    ///
291    /// # Errors
292    ///
293    /// Returns an io error when flushing the log or writing any report file
294    /// fails.
295    #[allow(clippy::significant_drop_tightening)]
296    pub fn finish(&self) -> io::Result<()> {
297        let mut state = lock(&self.state);
298        if let Some(writer) = state.jsonl.as_mut() {
299            writer.flush()?;
300        }
301        Self::write_junit(&state)?;
302        Self::write_trace(&state)?;
303        self.write_github_summary();
304        Ok(())
305    }
306
307    #[allow(clippy::significant_drop_tightening)]
308    fn write_jsonl(&self, event: &TestEvent) -> io::Result<()> {
309        let mut state = lock(&self.state);
310        let Some(writer) = state.jsonl.as_mut() else {
311            return Ok(());
312        };
313        let mut value = serde_json::to_value(event).map_err(io::Error::other)?;
314        if let Some(obj) = value.as_object_mut() {
315            obj.insert("ts".to_owned(), json!(now_ms()));
316        }
317        serde_json::to_writer(&mut *writer, &value).map_err(io::Error::other)?;
318        writer.write_all(b"\n")?;
319        Ok(())
320    }
321
322    // ── Perfetto trace sink ──────────────────────────────────────────────
323
324    #[allow(clippy::significant_drop_tightening)]
325    fn track_trace(&self, event: &TestEvent) {
326        let mut state = lock(&self.state);
327        if state.trace_path.is_none() {
328            return;
329        }
330        state.trace_base.get_or_insert_with(Instant::now);
331        match event {
332            TestEvent::TestStarted { test } => {
333                state.trace_open.push(OpenSpan {
334                    kind: 0,
335                    name: test.clone(),
336                    start: Instant::now(),
337                });
338            }
339            TestEvent::StepStarted { label, .. } => {
340                state.trace_open.push(OpenSpan {
341                    kind: 1,
342                    name: label.clone(),
343                    start: Instant::now(),
344                });
345            }
346            TestEvent::LlmCallStarted {
347                endpoint,
348                model,
349                purpose,
350                ..
351            } => {
352                state.trace_open.push(OpenSpan {
353                    kind: 2,
354                    name: format!("llm {purpose}: {endpoint} ({model})"),
355                    start: Instant::now(),
356                });
357            }
358            TestEvent::TestFinished {
359                test,
360                passed,
361                failed,
362                skipped,
363                ..
364            } => {
365                close_span(
366                    &mut state,
367                    0,
368                    &json!({"test": test, "passed": passed, "failed": failed, "skipped": skipped}),
369                );
370            }
371            TestEvent::StepFinished { status, .. } => {
372                close_span(&mut state, 1, &json!({"status": status}));
373            }
374            TestEvent::LlmCallFinished {
375                ok,
376                input_tokens,
377                output_tokens,
378                cost,
379                error,
380                ..
381            } => {
382                let args = json!({"ok": ok, "in_tokens": input_tokens, "out_tokens": output_tokens, "cost": cost, "error": error});
383                close_span(&mut state, 2, &args);
384            }
385            _ => {}
386        }
387    }
388
389    fn write_trace(state: &ReporterState) -> io::Result<()> {
390        let Some(path) = state.trace_path.as_deref() else {
391            return Ok(());
392        };
393        let doc = json!({
394            "traceEvents": state.trace_events,
395            "displayTimeUnit": "ms",
396        });
397        let mut writer = BufWriter::new(File::create(path)?);
398        serde_json::to_writer_pretty(&mut writer, &doc).map_err(io::Error::other)?;
399        writer.write_all(b"\n")?;
400        writer.flush()
401    }
402
403    // ── JUnit sink ───────────────────────────────────────────────────────
404
405    #[allow(clippy::significant_drop_tightening)]
406    fn track_junit(&self, event: &TestEvent) {
407        let mut state = lock(&self.state);
408        if state.junit_path.is_none() {
409            return;
410        }
411        match event {
412            TestEvent::StepFinished {
413                test,
414                label,
415                status,
416                message,
417                diagnostics,
418                screenshot,
419                ..
420            } => {
421                let case = locate_case(&mut state, test);
422                match status {
423                    StepStatus::Failed => {
424                        let mut text = format!("{label}: {message}");
425                        if let Some(diag) = diagnostics {
426                            text.push('\n');
427                            text.push_str(diag);
428                        }
429                        if let Some(shot) = screenshot {
430                            use std::fmt::Write as _;
431                            let _ = write!(text, "\nscreenshot: {shot}");
432                        }
433                        case.failures.push(text);
434                    }
435                    StepStatus::Skipped => case.skipped += 1,
436                    StepStatus::Passed => {}
437                }
438            }
439            TestEvent::TestFinished {
440                test, duration_ms, ..
441            } => {
442                let case = locate_case(&mut state, test);
443                case.duration_ms = *duration_ms;
444            }
445            _ => {}
446        }
447    }
448
449    fn write_junit(state: &ReporterState) -> io::Result<()> {
450        let Some(path) = state.junit_path.as_deref() else {
451            return Ok(());
452        };
453        write_junit_xml(&state.junit_cases, "llm-browser-testkit", path)
454    }
455
456    // ── GitHub sink ──────────────────────────────────────────────────────
457
458    fn track_github(&self, event: &TestEvent) {
459        if !self.github_annotations {
460            return;
461        }
462        match event {
463            TestEvent::StepFinished {
464                test,
465                label,
466                status,
467                message,
468                screenshot,
469                ..
470            } => {
471                if *status == StepStatus::Failed {
472                    let mut props = Vec::new();
473                    if let Some(shot) = screenshot {
474                        props.push(format!("file={}", escape_property(shot)));
475                    }
476                    props.push(format!(
477                        "title={}:{}",
478                        escape_property(test),
479                        escape_property(label)
480                    ));
481                    let _ = writeln!(
482                        io::stdout().lock(),
483                        "::error {}::{}",
484                        props.join(","),
485                        escape_data(message)
486                    );
487                }
488            }
489            TestEvent::RunFinished {
490                tests_passed,
491                tests_failed,
492                steps_passed,
493                steps_failed,
494                steps_skipped,
495                total_cost,
496                total_tokens,
497                total_calls,
498            } => {
499                *lock(&self.github_summary) = Some(RunSummary {
500                    tests_passed: *tests_passed,
501                    tests_failed: *tests_failed,
502                    steps_passed: *steps_passed,
503                    steps_failed: *steps_failed,
504                    steps_skipped: *steps_skipped,
505                    total_cost: *total_cost,
506                    total_tokens: *total_tokens,
507                    total_calls: *total_calls,
508                });
509            }
510            _ => {}
511        }
512    }
513
514    #[allow(clippy::significant_drop_tightening)]
515    fn write_github_summary(&self) {
516        let summary = lock(&self.github_summary);
517        let Some(summary) = *summary else {
518            return;
519        };
520        let Some(path) = std::env::var_os("GITHUB_STEP_SUMMARY") else {
521            return;
522        };
523        let Ok(mut file) = File::options().append(true).open(path) else {
524            return;
525        };
526        let verdict = if summary.tests_failed == 0 {
527            "✅ all tests passed"
528        } else {
529            "❌ some tests failed"
530        };
531        let _ = writeln!(file, "## llm-browser-testkit run: {verdict}\n");
532        let _ = writeln!(
533            file,
534            "- tests: {} passed, {} failed",
535            summary.tests_passed, summary.tests_failed
536        );
537        let _ = writeln!(
538            file,
539            "- steps: {} passed, {} failed, {} skipped",
540            summary.steps_passed, summary.steps_failed, summary.steps_skipped
541        );
542        let _ = writeln!(
543            file,
544            "- cost: ${:.4} | tokens: {} | calls: {}",
545            summary.total_cost, summary.total_tokens, summary.total_calls
546        );
547    }
548}
549
550/// Finds the `JUnit` case for a test, creating it on first sight.
551fn locate_case<'a>(state: &'a mut ReporterState, test: &str) -> &'a mut JunitCase {
552    let idx = state
553        .junit_cases
554        .iter()
555        .rposition(|c| c.name == test)
556        .unwrap_or_else(|| {
557            state.junit_cases.push(JunitCase::new(test.to_owned()));
558            state.junit_cases.len() - 1
559        });
560    &mut state.junit_cases[idx]
561}
562
563/// Closes the most recent open span of the given kind into a complete trace
564/// event (`ph: "X"`).
565#[allow(clippy::cast_possible_truncation)]
566fn close_span(state: &mut ReporterState, kind: u8, args: &serde_json::Value) {
567    let Some(idx) = state.trace_open.iter().rposition(|s| s.kind == kind) else {
568        return;
569    };
570    let span = state.trace_open.remove(idx);
571    let base = state
572        .trace_base
573        .expect("trace base set before any span opens");
574    let start_ts = if span.start >= base {
575        span.start.duration_since(base).as_micros() as u64
576    } else {
577        0
578    };
579    let dur_us = span.start.elapsed().as_micros() as u64;
580    let cat = match kind {
581        0 => "test",
582        1 => "step",
583        _ => "llm",
584    };
585    state.trace_events.push(json!({
586        "name": span.name,
587        "ph": "X",
588        "ts": start_ts,
589        "dur": dur_us,
590        "pid": 1,
591        "tid": 1,
592        "cat": cat,
593        "args": args,
594    }));
595}
596
597/// Serializes one event into its console text and required level.
598#[must_use]
599#[allow(clippy::too_many_lines)]
600fn format_event(event: &TestEvent, palette: Palette) -> (Level, String) {
601    match event {
602        TestEvent::RunStarted { total_tests } => {
603            (Level::Debug, format!("run started: {total_tests} test(s)"))
604        }
605        TestEvent::TestStarted { test } => (Level::Info, palette.cyan(&format!("Test: {test}"))),
606        TestEvent::StepStarted { label, .. } => (Level::Debug, format!("  - {label}")),
607        TestEvent::StepFinished {
608            label,
609            message,
610            duration_ms,
611            status,
612            diagnostics,
613            screenshot,
614            ..
615        } => {
616            let level = match status {
617                StepStatus::Failed => Level::Error,
618                StepStatus::Skipped => Level::Warn,
619                StepStatus::Passed => Level::Info,
620            };
621            let icon = match status {
622                StepStatus::Passed => palette.green("✓"),
623                StepStatus::Failed => palette.red("✗"),
624                StepStatus::Skipped => palette.yellow("–"),
625            };
626            let dur = palette.dim(&format_duration(*duration_ms));
627            let mut lines = vec![format!("  {icon} {label} — {message} ({dur})")];
628            if let Some(diag) = diagnostics {
629                lines.push(diag.clone());
630            }
631            if let Some(shot) = screenshot {
632                lines.push(format!("      screenshot: {shot}"));
633            }
634            (level, lines.join("\n"))
635        }
636        TestEvent::LlmCallStarted {
637            endpoint,
638            model,
639            purpose,
640            ..
641        } => (
642            Level::Debug,
643            format!("      llm({purpose}): {endpoint} ({model}) ..."),
644        ),
645        TestEvent::LlmCallFinished {
646            endpoint,
647            model,
648            purpose,
649            ok,
650            duration_ms,
651            input_tokens,
652            output_tokens,
653            cost,
654            error,
655            ..
656        } => {
657            let status = if *ok {
658                palette.green("ok")
659            } else {
660                palette.red("failed")
661            };
662            let dur = palette.dim(&format_duration(*duration_ms));
663            let mut line = format!(
664                "      llm({purpose}): {endpoint} ({model}) {status} {dur} | \
665                 {input_tokens} in / {output_tokens} out | ${cost:.4}"
666            );
667            if let Some(err) = error {
668                use std::fmt::Write as _;
669                let _ = write!(line, " — {err}");
670            }
671            let level = if *ok { Level::Debug } else { Level::Warn };
672            (level, line)
673        }
674        TestEvent::TestFinished {
675            test,
676            passed,
677            failed,
678            skipped,
679            duration_ms,
680            cost,
681            tokens,
682            calls,
683        } => {
684            let verdict = if *failed == 0 && *passed > 0 {
685                palette.green("passed")
686            } else {
687                palette.red("failed")
688            };
689            let dur = palette.dim(&format_duration(*duration_ms));
690            (
691                Level::Info,
692                format!(
693                    "Test: {test} — {verdict} ({dur}, ${cost:.4}, {tokens} tokens, \
694                     {calls} calls, {passed}+{failed}+{skipped} steps)"
695                ),
696            )
697        }
698        TestEvent::RunFinished {
699            tests_passed,
700            tests_failed,
701            steps_passed,
702            steps_failed,
703            steps_skipped,
704            total_cost,
705            total_tokens,
706            total_calls,
707        } => {
708            let verdict = if *tests_failed == 0 {
709                palette.green("passed")
710            } else {
711                palette.red("failed")
712            };
713            (
714                Level::Warn,
715                format!(
716                    "run {verdict}: tests {tests_passed} passed, {tests_failed} failed | \
717                     steps {steps_passed} passed, {steps_failed} failed, \
718                     {steps_skipped} skipped | ${total_cost:.4} | {total_tokens} tokens | \
719                     {total_calls} calls"
720                ),
721            )
722        }
723        TestEvent::Warning { message } => (Level::Warn, palette.yellow(&format!("  ! {message}"))),
724    }
725}
726
727/// Human-friendly duration: `431ms` or `1.2s`.
728fn format_duration(ms: u64) -> String {
729    if ms < 1000 {
730        format!("{ms}ms")
731    } else {
732        #[allow(clippy::cast_precision_loss)]
733        let secs = ms as f64 / 1000.0;
734        format!("{secs:.1}s")
735    }
736}
737
738/// Epoch milliseconds, for the NDJSON `ts` field.
739#[allow(clippy::cast_possible_truncation)]
740fn now_ms() -> u64 {
741    SystemTime::now()
742        .duration_since(UNIX_EPOCH)
743        .unwrap_or_default()
744        .as_millis() as u64
745}
746
747/// Escapes a string for the data section of a GitHub workflow command.
748#[must_use]
749fn escape_data(s: &str) -> String {
750    s.replace('%', "%25")
751        .replace('\r', "%0D")
752        .replace('\n', "%0A")
753}
754
755/// Escapes a string for the property section of a GitHub workflow command.
756#[must_use]
757fn escape_property(s: &str) -> String {
758    s.replace('%', "%25")
759        .replace('\r', "%0D")
760        .replace('\n', "%0A")
761        .replace(':', "%3A")
762        .replace(',', "%2C")
763}
764
765/// Escapes a string for use in an XML attribute value.
766#[must_use]
767fn esc_attr(s: &str) -> String {
768    s.replace('&', "&amp;")
769        .replace('<', "&lt;")
770        .replace('>', "&gt;")
771        .replace('"', "&quot;")
772        .replace('\'', "&apos;")
773}
774
775/// Writes the accumulated cases as a `JUnit` XML document.
776fn write_junit_xml(cases: &[JunitCase], classname: &str, path: &Path) -> io::Result<()> {
777    let tests = cases.len();
778    let failures: usize = cases.iter().map(|c| c.failures.len()).sum();
779    let skipped: u32 = cases.iter().map(|c| c.skipped).sum();
780    #[allow(clippy::cast_precision_loss)]
781    let time: f64 = cases.iter().map(|c| c.duration_ms as f64 / 1000.0).sum();
782
783    let mut writer = BufWriter::new(File::create(path)?);
784    writer.write_all(b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")?;
785    writeln!(
786        writer,
787        "<testsuites tests=\"{tests}\" failures=\"{failures}\" skipped=\"{skipped}\" \
788         time=\"{time:.3}\">"
789    )?;
790    for case in cases {
791        let name = esc_attr(&case.name);
792        #[allow(clippy::cast_precision_loss)]
793        let case_time = case.duration_ms as f64 / 1000.0;
794        if case.failures.is_empty() && case.skipped == 0 {
795            writeln!(
796                writer,
797                "  <testcase classname=\"{classname}\" name=\"{name}\" time=\"{case_time:.3}\"/>"
798            )?;
799        } else {
800            writeln!(
801                writer,
802                "  <testcase classname=\"{classname}\" name=\"{name}\" time=\"{case_time:.3}\">"
803            )?;
804            for _ in 0..case.skipped {
805                writeln!(writer, "    <skipped/>")?;
806            }
807            for failure in &case.failures {
808                write!(writer, "    <failure message=\"{}\">", esc_attr(failure))?;
809                writer.write_all(b"<![CDATA[")?;
810                writer.write_all(failure.replace("]]>", "]]]]><![CDATA[>").as_bytes())?;
811                writer.write_all(b"]]></failure>\n")?;
812            }
813            writeln!(writer, "  </testcase>")?;
814        }
815    }
816    writeln!(writer, "</testsuites>")?;
817    writer.flush()
818}
819
820/// Prints a cost report to stderr after all tests complete.
821pub fn print_report(per_test: &[(String, UsageSnapshot)], global: &UsageSnapshot) {
822    if per_test.is_empty() {
823        return;
824    }
825
826    eprintln!();
827    eprintln!("-------------------------------");
828    eprintln!("  COST REPORT");
829    eprintln!("-------------------------------");
830
831    for (test_name, snapshot) in per_test {
832        eprintln!(
833            "  Test: \"{test_name}\" — ${cost:.4} | {tokens} tokens | {calls} calls",
834            cost = snapshot.total_cost,
835            tokens = snapshot.total_tokens,
836            calls = snapshot.total_calls,
837        );
838        for (ep_name, ep_usage) in &snapshot.endpoints {
839            if ep_usage.calls == 0 {
840                continue;
841            }
842            eprintln!(
843                "    endpoint.{ep_name}:   {calls:>3} calls, {tokens:>7} tokens, ${cost:.4}",
844                calls = ep_usage.calls,
845                tokens = ep_usage.input_tokens + ep_usage.output_tokens,
846                cost = ep_usage.cost,
847            );
848        }
849    }
850
851    eprintln!("-------------------------------");
852    eprintln!("  GLOBAL SUMMARY");
853    eprintln!("    Total cost:     ${cost:.4}", cost = global.total_cost);
854    eprintln!("    Total tokens:   {tokens}", tokens = global.total_tokens);
855    eprintln!("    Total calls:    {calls}", calls = global.total_calls);
856    eprintln!("-------------------------------");
857}
858
859/// Prints a budget exceeded warning to stderr.
860pub fn print_budget_warning(message: &str) {
861    eprintln!("  ! BUDGET WARNING: {message}");
862}
863
864/// Prints a budget exceeded hard error to stderr.
865pub fn print_budget_error(message: &str) {
866    eprintln!("  ✗ BUDGET EXCEEDED: {message}");
867}
868
869#[cfg(test)]
870mod tests {
871    use std::io::Read;
872    use std::path::PathBuf;
873
874    use super::{
875        escape_data, escape_property, format_duration, format_event, print_report, ColorMode,
876        Level, Palette, Reporter,
877    };
878    use crate::costs::{EndpointUsage, UsageSnapshot};
879    use crate::events::{StepStatus, TestEvent};
880    use std::collections::HashMap;
881
882    fn palette() -> Palette {
883        Palette { enabled: false }
884    }
885
886    fn temp_file(name: &str) -> PathBuf {
887        let dir = std::env::temp_dir().join(format!("lbt-report-{}", std::process::id()));
888        std::fs::create_dir_all(&dir).expect("temp dir");
889        dir.join(name)
890    }
891
892    fn read_file(path: &PathBuf) -> String {
893        let mut s = String::new();
894        let mut f = std::fs::File::open(path).expect("open report file");
895        f.read_to_string(&mut s).expect("read report file");
896        s
897    }
898
899    fn step_event(status: StepStatus) -> TestEvent {
900        TestEvent::StepFinished {
901            test: "login".into(),
902            index: 0,
903            label: "[click] sign in".into(),
904            status,
905            duration_ms: 1200,
906            message: if status == StepStatus::Failed {
907                "element #btn not found".into()
908            } else {
909                "clicked #btn".into()
910            },
911            diagnostics: (status == StepStatus::Failed).then(|| "    | url: http://x".into()),
912            screenshot: (status == StepStatus::Failed).then(|| "artifacts/login.png".into()),
913        }
914    }
915
916    #[test]
917    fn test_level_from_flags() {
918        assert_eq!(Level::from_flags(0, 0), Level::Info);
919        assert_eq!(Level::from_flags(0, 1), Level::Debug);
920        assert_eq!(Level::from_flags(0, 2), Level::Trace);
921        assert_eq!(Level::from_flags(0, 9), Level::Trace);
922        assert_eq!(Level::from_flags(1, 0), Level::Warn);
923        assert_eq!(Level::from_flags(1, 5), Level::Warn, "quiet wins");
924        assert_eq!(Level::from_flags(2, 0), Level::Error);
925        assert_eq!(Level::from_flags(3, 0), Level::Error);
926    }
927
928    #[test]
929    fn test_format_step_finished_failed_includes_diagnostics() {
930        let (level, text) = format_event(&step_event(StepStatus::Failed), palette());
931        assert_eq!(level, Level::Error);
932        assert!(text.contains("[click] sign in"));
933        assert!(text.contains("element #btn not found"));
934        assert!(text.contains("1.2s"));
935        assert!(text.contains("| url: http://x"));
936        assert!(text.contains("screenshot: artifacts/login.png"));
937        assert!(!text.contains("\x1b["), "no ANSI codes when color disabled");
938    }
939
940    #[test]
941    fn test_format_step_finished_passed_level_info() {
942        let (level, text) = format_event(&step_event(StepStatus::Passed), palette());
943        assert_eq!(level, Level::Info);
944        assert!(text.contains("clicked #btn"));
945        assert!(!text.contains("screenshot:"));
946    }
947
948    #[test]
949    fn test_format_test_finished_verdict() {
950        let ok = format_event(
951            &TestEvent::TestFinished {
952                test: "t1".into(),
953                passed: 3,
954                failed: 0,
955                skipped: 0,
956                duration_ms: 6100,
957                cost: 0.0123,
958                tokens: 1234,
959                calls: 4,
960            },
961            palette(),
962        );
963        assert_eq!(ok.0, Level::Info);
964        assert!(ok.1.contains("passed"));
965
966        let bad = format_event(
967            &TestEvent::TestFinished {
968                test: "t2".into(),
969                passed: 1,
970                failed: 1,
971                skipped: 2,
972                duration_ms: 6100,
973                cost: 0.0123,
974                tokens: 1234,
975                calls: 4,
976            },
977            palette(),
978        );
979        assert!(bad.1.contains("failed"));
980    }
981
982    #[test]
983    fn test_format_llm_call_lines() {
984        let started = format_event(
985            &TestEvent::LlmCallStarted {
986                test: "t".into(),
987                index: 0,
988                endpoint: "default".into(),
989                model: "deepseek".into(),
990                purpose: "targeting".into(),
991            },
992            palette(),
993        );
994        assert_eq!(started.0, Level::Debug);
995        assert!(started.1.contains("llm(targeting): default (deepseek)"));
996
997        let failed = format_event(
998            &TestEvent::LlmCallFinished {
999                test: "t".into(),
1000                index: 0,
1001                endpoint: "default".into(),
1002                model: "deepseek".into(),
1003                purpose: "targeting".into(),
1004                ok: false,
1005                duration_ms: 900,
1006                input_tokens: 100,
1007                output_tokens: 0,
1008                cost: 0.0012,
1009                error: Some("HTTP 429: slow down".into()),
1010            },
1011            palette(),
1012        );
1013        assert_eq!(failed.0, Level::Warn);
1014        assert!(failed.1.contains("failed"));
1015        assert!(failed.1.contains("HTTP 429"));
1016    }
1017
1018    #[test]
1019    fn test_format_run_started_is_debug() {
1020        let (level, text) = format_event(&TestEvent::RunStarted { total_tests: 3 }, palette());
1021        assert_eq!(level, Level::Debug);
1022        assert!(text.contains("3 test(s)"));
1023    }
1024
1025    #[test]
1026    fn test_format_duration() {
1027        assert_eq!(format_duration(0), "0ms");
1028        assert_eq!(format_duration(431), "431ms");
1029        assert_eq!(format_duration(1000), "1.0s");
1030        assert_eq!(format_duration(1234), "1.2s");
1031    }
1032
1033    #[test]
1034    fn test_github_escaping() {
1035        assert_eq!(escape_data("a% b\nc\rd"), "a%25 b%0Ac%0Dd");
1036        assert_eq!(escape_property("a:b,c\n%d"), "a%3Ab%2Cc%0A%25d");
1037    }
1038
1039    #[test]
1040    fn test_reporter_jsonl_emits_typed_timestamped_lines() {
1041        let path = temp_file("run.jsonl");
1042        let reporter = Reporter::new(
1043            Level::Debug,
1044            ColorMode::Never,
1045            Some(&path),
1046            None,
1047            None,
1048            false,
1049        )
1050        .expect("open jsonl");
1051        reporter
1052            .emit(&TestEvent::RunStarted { total_tests: 1 })
1053            .expect("emit");
1054        reporter
1055            .emit(&TestEvent::StepStarted {
1056                test: "login".into(),
1057                index: 0,
1058                label: "[click] sign in".into(),
1059            })
1060            .expect("emit");
1061        reporter
1062            .emit(&step_event(StepStatus::Passed))
1063            .expect("emit");
1064        reporter
1065            .emit(&TestEvent::TestFinished {
1066                test: "login".into(),
1067                passed: 1,
1068                failed: 0,
1069                skipped: 0,
1070                duration_ms: 1200,
1071                cost: 0.001,
1072                tokens: 100,
1073                calls: 1,
1074            })
1075            .expect("emit");
1076        reporter
1077            .emit(&TestEvent::RunFinished {
1078                tests_passed: 1,
1079                tests_failed: 0,
1080                steps_passed: 1,
1081                steps_failed: 0,
1082                steps_skipped: 0,
1083                total_cost: 0.001,
1084                total_tokens: 100,
1085                total_calls: 1,
1086            })
1087            .expect("emit");
1088        reporter.finish().expect("finish");
1089
1090        let content = read_file(&path);
1091        let lines: Vec<&str> = content.lines().collect();
1092        assert_eq!(lines.len(), 5);
1093        let first: serde_json::Value = serde_json::from_str(lines[0]).expect("json line");
1094        assert_eq!(first["type"], "run_started");
1095        assert!(first["ts"].as_u64().expect("ts") > 0);
1096        let step: serde_json::Value = serde_json::from_str(lines[2]).expect("json line");
1097        assert_eq!(step["type"], "step_finished");
1098        assert_eq!(step["status"], "passed");
1099        assert_eq!(step["test"], "login");
1100        let finished: serde_json::Value = serde_json::from_str(lines[4]).expect("json line");
1101        assert_eq!(finished["type"], "run_finished");
1102        assert_eq!(finished["tests_passed"], 1);
1103    }
1104
1105    #[test]
1106    fn test_reporter_junit_creates_failure_elements() {
1107        let path = temp_file("report.xml");
1108        let reporter = Reporter::new(
1109            Level::Info,
1110            ColorMode::Never,
1111            None,
1112            Some(&path),
1113            None,
1114            false,
1115        )
1116        .expect("open junit");
1117        reporter
1118            .emit(&step_event(StepStatus::Failed))
1119            .expect("emit");
1120        reporter
1121            .emit(&step_event(StepStatus::Skipped))
1122            .expect("emit");
1123        reporter
1124            .emit(&TestEvent::TestFinished {
1125                test: "login".into(),
1126                passed: 0,
1127                failed: 1,
1128                skipped: 1,
1129                duration_ms: 5000,
1130                cost: 0.0,
1131                tokens: 0,
1132                calls: 0,
1133            })
1134            .expect("emit");
1135        reporter.finish().expect("finish");
1136
1137        let xml = read_file(&path);
1138        assert!(xml.starts_with("<?xml version=\"1.0\""));
1139        assert!(xml.contains("<testsuites tests=\"1\" failures=\"1\" skipped=\"1\""));
1140        assert!(xml.contains("name=\"login\""));
1141        assert!(xml.contains("<skipped/>"));
1142        assert!(xml.contains("<failure"));
1143        assert!(xml.contains("element #btn not found"));
1144        assert!(xml.contains("screenshot: artifacts/login.png"));
1145        // XML safety: a failure message with angle brackets must be escaped
1146        let weird = TestEvent::StepFinished {
1147            test: "weird".into(),
1148            index: 0,
1149            label: "[wait] <weird>".into(),
1150            status: StepStatus::Failed,
1151            duration_ms: 10,
1152            message: "boom <tag> & \"quote\"".into(),
1153            diagnostics: None,
1154            screenshot: None,
1155        };
1156        reporter.emit(&weird).expect("emit");
1157        reporter
1158            .emit(&TestEvent::TestFinished {
1159                test: "weird".into(),
1160                passed: 0,
1161                failed: 1,
1162                skipped: 0,
1163                duration_ms: 10,
1164                cost: 0.0,
1165                tokens: 0,
1166                calls: 0,
1167            })
1168            .expect("emit");
1169        reporter.finish().expect("finish");
1170        let xml2 = read_file(&path);
1171        // Attribute copies are XML-escaped...
1172        assert!(xml2.contains("&lt;weird&gt;") && xml2.contains("&amp; &quot;quote&quot;"));
1173        // ...while the CDATA body keeps the raw text.
1174        assert!(xml2.contains("[wait] <weird>: boom <tag> & \"quote\""));
1175    }
1176
1177    #[test]
1178    fn test_reporter_trace_emits_complete_spans() {
1179        let path = temp_file("trace.json");
1180        let reporter = Reporter::new(
1181            Level::Info,
1182            ColorMode::Never,
1183            None,
1184            None,
1185            Some(&path),
1186            false,
1187        )
1188        .expect("open trace");
1189        reporter
1190            .emit(&TestEvent::TestStarted {
1191                test: "login".into(),
1192            })
1193            .expect("emit");
1194        reporter
1195            .emit(&TestEvent::StepStarted {
1196                test: "login".into(),
1197                index: 0,
1198                label: "[click] sign in".into(),
1199            })
1200            .expect("emit");
1201        reporter
1202            .emit(&step_event(StepStatus::Passed))
1203            .expect("emit");
1204        reporter
1205            .emit(&TestEvent::TestFinished {
1206                test: "login".into(),
1207                passed: 1,
1208                failed: 0,
1209                skipped: 0,
1210                duration_ms: 1200,
1211                cost: 0.0,
1212                tokens: 0,
1213                calls: 0,
1214            })
1215            .expect("emit");
1216        reporter.finish().expect("finish");
1217
1218        let content = read_file(&path);
1219        let doc: serde_json::Value = serde_json::from_str(&content).expect("trace json");
1220        let events = doc["traceEvents"].as_array().expect("traceEvents array");
1221        assert_eq!(events.len(), 2);
1222        // Spans close inner-first, so the array starts with the step.
1223        let step = &events[0];
1224        assert_eq!(step["name"], "[click] sign in");
1225        assert_eq!(step["ph"], "X");
1226        assert_eq!(step["cat"], "step");
1227        assert_eq!(events[1]["name"], "login");
1228        assert_eq!(events[1]["cat"], "test");
1229        assert!(step["ts"].as_u64().expect("ts") > 0);
1230        assert_eq!(doc["displayTimeUnit"], "ms");
1231    }
1232
1233    #[test]
1234    fn test_print_report_empty() {
1235        // Should return early, no panic
1236        print_report(&[], &UsageSnapshot::default());
1237    }
1238
1239    #[test]
1240    fn test_print_report_single_test() {
1241        let per_test = vec![("test1".to_owned(), make_snapshot(0.05, 500, 3))];
1242        // Should not panic
1243        print_report(&per_test, &make_snapshot(0.05, 500, 3));
1244    }
1245
1246    fn make_snapshot(cost: f64, tokens: u64, calls: u64) -> UsageSnapshot {
1247        let mut eps = HashMap::new();
1248        eps.insert(
1249            "default".to_owned(),
1250            EndpointUsage {
1251                calls,
1252                input_tokens: tokens / 2,
1253                output_tokens: tokens / 2,
1254                cost,
1255            },
1256        );
1257        UsageSnapshot::from_endpoints(&eps)
1258    }
1259}