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