1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
37pub enum Level {
38 Error = 0,
40 Warn = 1,
42 Info = 2,
44 Debug = 3,
46 Trace = 4,
48}
49
50impl Level {
51 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
77pub enum ColorMode {
78 #[default]
80 Auto,
81 Always,
83 Never,
85}
86
87#[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#[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#[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
150struct 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
169struct OpenSpan {
171 kind: u8,
172 name: String,
173 start: Instant,
174}
175
176struct 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
188fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
190 mutex
191 .lock()
192 .unwrap_or_else(std::sync::PoisonError::into_inner)
193}
194
195pub 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 #[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 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 pub fn add_redaction_secret(&self, value: &str) {
276 lock(&self.redactor).add_secret(value, 0);
277 }
278
279 pub fn add_redaction_secrets(&self, values: impl IntoIterator<Item = String>) {
282 lock(&self.redactor).add_secret_values(values);
283 }
284
285 pub fn info(&self, msg: impl AsRef<str>) {
287 self.line(Level::Info, msg.as_ref());
288 }
289
290 pub fn debug(&self, msg: impl AsRef<str>) {
292 self.line(Level::Debug, msg.as_ref());
293 }
294
295 pub fn warn(&self, msg: impl AsRef<str>) {
297 self.line(Level::Warn, &format!(" ! {}", msg.as_ref()));
298 }
299
300 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 #[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 #[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 #[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 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
591fn 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#[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#[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
783fn 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#[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#[must_use]
805fn escape_data(s: &str) -> String {
806 s.replace('%', "%25")
807 .replace('\r', "%0D")
808 .replace('\n', "%0A")
809}
810
811#[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#[must_use]
823fn esc_attr(s: &str) -> String {
824 s.replace('&', "&")
825 .replace('<', "<")
826 .replace('>', ">")
827 .replace('"', """)
828 .replace('\'', "'")
829}
830
831fn 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
876pub 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
966pub fn print_budget_warning(message: &str) {
968 eprintln!(" ! BUDGET WARNING: {message}");
969}
970
971pub 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 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 assert!(xml2.contains("<weird>") && xml2.contains("& "quote""));
1308 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 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 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 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}