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