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 total_cache_creation_input_tokens: u64,
147 models: Vec<String>,
148 total_calls: u64,
149}
150
151struct JunitCase {
153 name: String,
154 duration_ms: u64,
155 failures: Vec<String>,
156 skipped: u32,
157}
158
159impl JunitCase {
160 const fn new(name: String) -> Self {
161 Self {
162 name,
163 duration_ms: 0,
164 failures: Vec::new(),
165 skipped: 0,
166 }
167 }
168}
169
170struct OpenSpan {
172 kind: u8,
173 name: String,
174 start: Instant,
175}
176
177struct ReporterState {
180 jsonl: Option<BufWriter<File>>,
181 junit_path: Option<PathBuf>,
182 junit_cases: Vec<JunitCase>,
183 trace_path: Option<PathBuf>,
184 trace_events: Vec<serde_json::Value>,
185 trace_open: Vec<OpenSpan>,
186 trace_base: Option<Instant>,
187}
188
189fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
191 mutex
192 .lock()
193 .unwrap_or_else(std::sync::PoisonError::into_inner)
194}
195
196pub struct Reporter {
198 level: Level,
199 palette: Palette,
200 github_annotations: bool,
201 github_summary: Mutex<Option<RunSummary>>,
202 redactor: Mutex<Redactor>,
203 state: Mutex<ReporterState>,
204}
205
206impl Default for Reporter {
207 fn default() -> Self {
208 Self::new(Level::Info, ColorMode::Auto, None, None, None, false)
209 .expect("default reporter has no files to open")
210 }
211}
212
213impl Reporter {
214 #[allow(clippy::too_many_arguments)]
221 pub fn new(
222 level: Level,
223 color: ColorMode,
224 log_file: Option<&Path>,
225 junit_file: Option<&Path>,
226 trace_file: Option<&Path>,
227 github_annotations: bool,
228 ) -> io::Result<Self> {
229 let jsonl = match log_file {
230 Some(path) => Some(BufWriter::new(File::create(path)?)),
231 None => None,
232 };
233 Ok(Self {
234 level,
235 palette: Palette {
236 enabled: detect_color(color),
237 },
238 github_annotations,
239 github_summary: Mutex::new(None),
240 redactor: Mutex::new(Redactor::new()),
241 state: Mutex::new(ReporterState {
242 jsonl,
243 junit_path: junit_file.map(Path::to_path_buf),
244 junit_cases: Vec::new(),
245 trace_path: trace_file.map(Path::to_path_buf),
246 trace_events: Vec::new(),
247 trace_open: Vec::new(),
248 trace_base: None,
249 }),
250 })
251 }
252
253 pub fn emit(&self, event: &TestEvent) -> io::Result<()> {
261 let event = lock(&self.redactor).redact_event(event);
262 let (level, text) = format_event(&event, self.palette);
263 if self.level.shows(level) {
264 let _ = writeln!(io::stderr().lock(), "{text}");
265 }
266 self.write_jsonl(&event)?;
267 self.track_trace(&event);
268 self.track_junit(&event);
269 self.track_github(&event);
270 Ok(())
271 }
272
273 pub fn add_redaction_secret(&self, value: &str) {
277 lock(&self.redactor).add_secret(value, 0);
278 }
279
280 pub fn add_redaction_secrets(&self, values: impl IntoIterator<Item = String>) {
283 lock(&self.redactor).add_secret_values(values);
284 }
285
286 pub fn info(&self, msg: impl AsRef<str>) {
288 self.line(Level::Info, msg.as_ref());
289 }
290
291 pub fn debug(&self, msg: impl AsRef<str>) {
293 self.line(Level::Debug, msg.as_ref());
294 }
295
296 pub fn warn(&self, msg: impl AsRef<str>) {
298 self.line(Level::Warn, &format!(" ! {}", msg.as_ref()));
299 }
300
301 pub fn error(&self, msg: impl AsRef<str>) {
303 let text = self.palette.red(&format!(" ✗ {}", msg.as_ref()));
304 self.line(Level::Error, &text);
305 }
306
307 fn line(&self, level: Level, text: &str) {
308 if self.level.shows(level) {
309 let text = lock(&self.redactor).redact(text);
310 let _ = writeln!(io::stderr().lock(), "{text}");
311 }
312 }
313
314 #[allow(clippy::significant_drop_tightening)]
322 pub fn finish(&self) -> io::Result<()> {
323 let mut state = lock(&self.state);
324 if let Some(writer) = state.jsonl.as_mut() {
325 writer.flush()?;
326 }
327 Self::write_junit(&state)?;
328 Self::write_trace(&state)?;
329 self.write_github_summary();
330 Ok(())
331 }
332
333 #[allow(clippy::significant_drop_tightening)]
334 fn write_jsonl(&self, event: &TestEvent) -> io::Result<()> {
335 let mut state = lock(&self.state);
336 let Some(writer) = state.jsonl.as_mut() else {
337 return Ok(());
338 };
339 let mut value = serde_json::to_value(event).map_err(io::Error::other)?;
340 if let Some(obj) = value.as_object_mut() {
341 obj.insert("ts".to_owned(), json!(now_ms()));
342 }
343 serde_json::to_writer(&mut *writer, &value).map_err(io::Error::other)?;
344 writer.write_all(b"\n")?;
345 Ok(())
346 }
347
348 #[allow(clippy::significant_drop_tightening)]
351 fn track_trace(&self, event: &TestEvent) {
352 let mut state = lock(&self.state);
353 if state.trace_path.is_none() {
354 return;
355 }
356 state.trace_base.get_or_insert_with(Instant::now);
357 match event {
358 TestEvent::TestStarted { test } => {
359 state.trace_open.push(OpenSpan {
360 kind: 0,
361 name: test.clone(),
362 start: Instant::now(),
363 });
364 }
365 TestEvent::StepStarted { label, .. } => {
366 state.trace_open.push(OpenSpan {
367 kind: 1,
368 name: label.clone(),
369 start: Instant::now(),
370 });
371 }
372 TestEvent::LlmCallStarted {
373 endpoint,
374 model,
375 purpose,
376 ..
377 } => {
378 state.trace_open.push(OpenSpan {
379 kind: 2,
380 name: format!("llm {purpose}: {endpoint} ({model})"),
381 start: Instant::now(),
382 });
383 }
384 TestEvent::TestFinished {
385 test,
386 passed,
387 failed,
388 skipped,
389 ..
390 } => {
391 close_span(
392 &mut state,
393 0,
394 &json!({"test": test, "passed": passed, "failed": failed, "skipped": skipped}),
395 );
396 }
397 TestEvent::StepFinished { status, .. } => {
398 close_span(&mut state, 1, &json!({"status": status}));
399 }
400 TestEvent::LlmCallFinished {
401 ok,
402 input_tokens,
403 output_tokens,
404 cost,
405 error,
406 ..
407 } => {
408 let args = json!({"ok": ok, "in_tokens": input_tokens, "out_tokens": output_tokens, "cost": cost, "error": error});
409 close_span(&mut state, 2, &args);
410 }
411 _ => {}
412 }
413 }
414
415 fn write_trace(state: &ReporterState) -> io::Result<()> {
416 let Some(path) = state.trace_path.as_deref() else {
417 return Ok(());
418 };
419 let doc = json!({
420 "traceEvents": state.trace_events,
421 "displayTimeUnit": "ms",
422 });
423 let mut writer = BufWriter::new(File::create(path)?);
424 serde_json::to_writer_pretty(&mut writer, &doc).map_err(io::Error::other)?;
425 writer.write_all(b"\n")?;
426 writer.flush()
427 }
428
429 #[allow(clippy::significant_drop_tightening)]
432 fn track_junit(&self, event: &TestEvent) {
433 let mut state = lock(&self.state);
434 if state.junit_path.is_none() {
435 return;
436 }
437 match event {
438 TestEvent::StepFinished {
439 test,
440 label,
441 status,
442 message,
443 diagnostics,
444 screenshot,
445 ..
446 } => {
447 let case = locate_case(&mut state, test);
448 match status {
449 StepStatus::Failed => {
450 let mut text = format!("{label}: {message}");
451 if let Some(diag) = diagnostics {
452 text.push('\n');
453 text.push_str(diag);
454 }
455 if let Some(shot) = screenshot {
456 use std::fmt::Write as _;
457 let _ = write!(text, "\nscreenshot: {shot}");
458 }
459 case.failures.push(text);
460 }
461 StepStatus::Skipped => case.skipped += 1,
462 StepStatus::Passed => {}
463 }
464 }
465 TestEvent::TestFinished {
466 test, duration_ms, ..
467 } => {
468 let case = locate_case(&mut state, test);
469 case.duration_ms = *duration_ms;
470 }
471 _ => {}
472 }
473 }
474
475 fn write_junit(state: &ReporterState) -> io::Result<()> {
476 let Some(path) = state.junit_path.as_deref() else {
477 return Ok(());
478 };
479 write_junit_xml(&state.junit_cases, "llm-browser-testkit", path)
480 }
481
482 fn track_github(&self, event: &TestEvent) {
485 if !self.github_annotations {
486 return;
487 }
488 match event {
489 TestEvent::StepFinished {
490 test,
491 label,
492 status,
493 message,
494 screenshot,
495 ..
496 } => {
497 if *status == StepStatus::Failed {
498 let mut props = Vec::new();
499 if let Some(shot) = screenshot {
500 props.push(format!("file={}", escape_property(shot)));
501 }
502 props.push(format!(
503 "title={}:{}",
504 escape_property(test),
505 escape_property(label)
506 ));
507 let _ = writeln!(
508 io::stdout().lock(),
509 "::error {}::{}",
510 props.join(","),
511 escape_data(message)
512 );
513 }
514 }
515 TestEvent::RunFinished {
516 tests_passed,
517 tests_failed,
518 steps_passed,
519 steps_failed,
520 steps_skipped,
521 total_cost,
522 total_tokens,
523 total_input_tokens,
524 total_output_tokens,
525 total_cached_input_tokens,
526 total_cache_creation_input_tokens,
527 models,
528 total_calls,
529 } => {
530 *lock(&self.github_summary) = Some(RunSummary {
531 tests_passed: *tests_passed,
532 tests_failed: *tests_failed,
533 steps_passed: *steps_passed,
534 steps_failed: *steps_failed,
535 steps_skipped: *steps_skipped,
536 total_cost: *total_cost,
537 total_tokens: *total_tokens,
538 total_input_tokens: *total_input_tokens,
539 total_output_tokens: *total_output_tokens,
540 total_cached_input_tokens: *total_cached_input_tokens,
541 total_cache_creation_input_tokens: *total_cache_creation_input_tokens,
542 models: models.clone(),
543 total_calls: *total_calls,
544 });
545 }
546 _ => {}
547 }
548 }
549
550 #[allow(clippy::significant_drop_tightening)]
551 fn write_github_summary(&self) {
552 let summary = lock(&self.github_summary);
553 let Some(summary) = summary.as_ref() else {
554 return;
555 };
556 let Some(path) = std::env::var_os("GITHUB_STEP_SUMMARY") else {
557 return;
558 };
559 let Ok(mut file) = File::options().append(true).open(path) else {
560 return;
561 };
562 let verdict = if summary.tests_failed == 0 {
563 "✅ all tests passed"
564 } else {
565 "❌ some tests failed"
566 };
567 let _ = writeln!(file, "## llm-browser-testkit run: {verdict}\n");
568 let _ = writeln!(
569 file,
570 "- tests: {} passed, {} failed",
571 summary.tests_passed, summary.tests_failed
572 );
573 let _ = writeln!(
574 file,
575 "- steps: {} passed, {} failed, {} skipped",
576 summary.steps_passed, summary.steps_failed, summary.steps_skipped
577 );
578 let _ = writeln!(
579 file,
580 "- cost: ${:.4} | tokens: {} ({} in / {} out, {} cached, {} cache write) | calls: {}",
581 summary.total_cost,
582 summary.total_tokens,
583 summary.total_input_tokens,
584 summary.total_output_tokens,
585 summary.total_cached_input_tokens,
586 summary.total_cache_creation_input_tokens,
587 summary.total_calls
588 );
589 if !summary.models.is_empty() {
590 let _ = writeln!(file, "- models: {}", summary.models.join(", "));
591 }
592 }
593}
594
595fn locate_case<'a>(state: &'a mut ReporterState, test: &str) -> &'a mut JunitCase {
597 let idx = state
598 .junit_cases
599 .iter()
600 .rposition(|c| c.name == test)
601 .unwrap_or_else(|| {
602 state.junit_cases.push(JunitCase::new(test.to_owned()));
603 state.junit_cases.len() - 1
604 });
605 &mut state.junit_cases[idx]
606}
607
608#[allow(clippy::cast_possible_truncation)]
611fn close_span(state: &mut ReporterState, kind: u8, args: &serde_json::Value) {
612 let Some(idx) = state.trace_open.iter().rposition(|s| s.kind == kind) else {
613 return;
614 };
615 let span = state.trace_open.remove(idx);
616 let base = state
617 .trace_base
618 .expect("trace base set before any span opens");
619 let start_ts = if span.start >= base {
620 span.start.duration_since(base).as_micros() as u64
621 } else {
622 0
623 };
624 let dur_us = span.start.elapsed().as_micros() as u64;
625 let cat = match kind {
626 0 => "test",
627 1 => "step",
628 _ => "llm",
629 };
630 state.trace_events.push(json!({
631 "name": span.name,
632 "ph": "X",
633 "ts": start_ts,
634 "dur": dur_us,
635 "pid": 1,
636 "tid": 1,
637 "cat": cat,
638 "args": args,
639 }));
640}
641
642#[must_use]
644#[allow(clippy::too_many_lines)]
645fn format_event(event: &TestEvent, palette: Palette) -> (Level, String) {
646 match event {
647 TestEvent::RunStarted { total_tests } => {
648 (Level::Debug, format!("run started: {total_tests} test(s)"))
649 }
650 TestEvent::TestStarted { test } => (Level::Info, palette.cyan(&format!("Test: {test}"))),
651 TestEvent::StepStarted { label, .. } => (Level::Debug, format!(" - {label}")),
652 TestEvent::StepFinished {
653 label,
654 message,
655 duration_ms,
656 status,
657 diagnostics,
658 screenshot,
659 ..
660 } => {
661 let level = match status {
662 StepStatus::Failed => Level::Error,
663 StepStatus::Skipped => Level::Warn,
664 StepStatus::Passed => Level::Info,
665 };
666 let icon = match status {
667 StepStatus::Passed => palette.green("✓"),
668 StepStatus::Failed => palette.red("✗"),
669 StepStatus::Skipped => palette.yellow("–"),
670 };
671 let dur = palette.dim(&format_duration(*duration_ms));
672 let mut lines = vec![format!(" {icon} {label} — {message} ({dur})")];
673 if let Some(diag) = diagnostics {
674 lines.push(diag.clone());
675 }
676 if let Some(shot) = screenshot {
677 lines.push(format!(" screenshot: {shot}"));
678 }
679 (level, lines.join("\n"))
680 }
681 TestEvent::LlmCallStarted {
682 endpoint,
683 model,
684 purpose,
685 ..
686 } => (
687 Level::Debug,
688 format!(" llm({purpose}): {endpoint} ({model}) ..."),
689 ),
690 TestEvent::LlmCallFinished {
691 endpoint,
692 model,
693 purpose,
694 ok,
695 duration_ms,
696 input_tokens,
697 output_tokens,
698 cached_input_tokens,
699 cache_creation_input_tokens,
700 cost,
701 error,
702 ..
703 } => {
704 let status = if *ok {
705 palette.green("ok")
706 } else {
707 palette.red("failed")
708 };
709 let dur = palette.dim(&format_duration(*duration_ms));
710 let mut line = format!(
711 " llm({purpose}): {endpoint} ({model}) {status} {dur} | \
712 {input_tokens} in / {output_tokens} out \
713 ({cached_input_tokens} cached, {cache_creation_input_tokens} cache write) | \
714 ${cost:.4}"
715 );
716 if let Some(err) = error {
717 use std::fmt::Write as _;
718 let _ = write!(line, " — {err}");
719 }
720 let level = if *ok { Level::Debug } else { Level::Warn };
721 (level, line)
722 }
723 TestEvent::TestFinished {
724 test,
725 passed,
726 failed,
727 skipped,
728 duration_ms,
729 cost,
730 tokens,
731 input_tokens,
732 output_tokens,
733 cached_input_tokens,
734 cache_creation_input_tokens,
735 models,
736 calls,
737 } => {
738 let verdict = if *failed == 0 && *passed > 0 {
739 palette.green("passed")
740 } else {
741 palette.red("failed")
742 };
743 let dur = palette.dim(&format_duration(*duration_ms));
744 let mut line = format!(
745 "Test: {test} — {verdict} ({dur}, ${cost:.4}, {tokens} tokens \
746 ({input_tokens} in / {output_tokens} out, {cached_input_tokens} cached, \
747 {cache_creation_input_tokens} cache write), \
748 {calls} calls, {passed}+{failed}+{skipped} steps)"
749 );
750 if !models.is_empty() {
751 use std::fmt::Write as _;
752 let _ = write!(line, " | models: {}", models.join(", "));
753 }
754 (Level::Info, line)
755 }
756 TestEvent::RunFinished {
757 tests_passed,
758 tests_failed,
759 steps_passed,
760 steps_failed,
761 steps_skipped,
762 total_cost,
763 total_tokens,
764 total_input_tokens,
765 total_output_tokens,
766 total_cached_input_tokens,
767 total_cache_creation_input_tokens,
768 models,
769 total_calls,
770 } => {
771 let verdict = if *tests_failed == 0 {
772 palette.green("passed")
773 } else {
774 palette.red("failed")
775 };
776 let mut line = format!(
777 "run {verdict}: tests {tests_passed} passed, {tests_failed} failed | \
778 steps {steps_passed} passed, {steps_failed} failed, \
779 {steps_skipped} skipped | ${total_cost:.4} | {total_tokens} tokens \
780 ({total_input_tokens} in / {total_output_tokens} out, \
781 {total_cached_input_tokens} cached, {total_cache_creation_input_tokens} cache write) \
782 | {total_calls} calls"
783 );
784 if !models.is_empty() {
785 use std::fmt::Write as _;
786 let _ = write!(line, " | models: {}", models.join(", "));
787 }
788 (Level::Warn, line)
789 }
790 TestEvent::Warning { message } => (Level::Warn, palette.yellow(&format!(" ! {message}"))),
791 }
792}
793
794fn format_duration(ms: u64) -> String {
796 if ms < 1000 {
797 format!("{ms}ms")
798 } else {
799 #[allow(clippy::cast_precision_loss)]
800 let secs = ms as f64 / 1000.0;
801 format!("{secs:.1}s")
802 }
803}
804
805#[allow(clippy::cast_possible_truncation)]
807fn now_ms() -> u64 {
808 SystemTime::now()
809 .duration_since(UNIX_EPOCH)
810 .unwrap_or_default()
811 .as_millis() as u64
812}
813
814#[must_use]
816fn escape_data(s: &str) -> String {
817 s.replace('%', "%25")
818 .replace('\r', "%0D")
819 .replace('\n', "%0A")
820}
821
822#[must_use]
824fn escape_property(s: &str) -> String {
825 s.replace('%', "%25")
826 .replace('\r', "%0D")
827 .replace('\n', "%0A")
828 .replace(':', "%3A")
829 .replace(',', "%2C")
830}
831
832#[must_use]
834fn esc_attr(s: &str) -> String {
835 s.replace('&', "&")
836 .replace('<', "<")
837 .replace('>', ">")
838 .replace('"', """)
839 .replace('\'', "'")
840}
841
842fn write_junit_xml(cases: &[JunitCase], classname: &str, path: &Path) -> io::Result<()> {
844 let tests = cases.len();
845 let failures: usize = cases.iter().map(|c| c.failures.len()).sum();
846 let skipped: u32 = cases.iter().map(|c| c.skipped).sum();
847 #[allow(clippy::cast_precision_loss)]
848 let time: f64 = cases.iter().map(|c| c.duration_ms as f64 / 1000.0).sum();
849
850 let mut writer = BufWriter::new(File::create(path)?);
851 writer.write_all(b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")?;
852 writeln!(
853 writer,
854 "<testsuites tests=\"{tests}\" failures=\"{failures}\" skipped=\"{skipped}\" \
855 time=\"{time:.3}\">"
856 )?;
857 for case in cases {
858 let name = esc_attr(&case.name);
859 #[allow(clippy::cast_precision_loss)]
860 let case_time = case.duration_ms as f64 / 1000.0;
861 if case.failures.is_empty() && case.skipped == 0 {
862 writeln!(
863 writer,
864 " <testcase classname=\"{classname}\" name=\"{name}\" time=\"{case_time:.3}\"/>"
865 )?;
866 } else {
867 writeln!(
868 writer,
869 " <testcase classname=\"{classname}\" name=\"{name}\" time=\"{case_time:.3}\">"
870 )?;
871 for _ in 0..case.skipped {
872 writeln!(writer, " <skipped/>")?;
873 }
874 for failure in &case.failures {
875 write!(writer, " <failure message=\"{}\">", esc_attr(failure))?;
876 writer.write_all(b"<![CDATA[")?;
877 writer.write_all(failure.replace("]]>", "]]]]><![CDATA[>").as_bytes())?;
878 writer.write_all(b"]]></failure>\n")?;
879 }
880 writeln!(writer, " </testcase>")?;
881 }
882 }
883 writeln!(writer, "</testsuites>")?;
884 writer.flush()
885}
886
887pub fn print_batch_report(
890 per_file: &[(String, UsageSnapshot)],
891 per_test: &[(String, UsageSnapshot)],
892 global: &UsageSnapshot,
893) {
894 if per_file.is_empty() {
895 print_report(per_test, global);
896 return;
897 }
898
899 eprintln!();
900 eprintln!("-------------------------------");
901 eprintln!(" PER-FILE SUMMARY");
902 eprintln!("-------------------------------");
903 for (label, snapshot) in per_file {
904 eprintln!(
905 " {label}: ${cost:.4} | {tokens} tokens \
906 ({input} in / {output} out, {cached} cached, {write} cache write) | {calls} calls",
907 cost = snapshot.total_cost,
908 tokens = snapshot.total_tokens,
909 input = snapshot.total_input_tokens,
910 output = snapshot.total_output_tokens,
911 cached = snapshot.total_cached_input_tokens,
912 write = snapshot.total_cache_creation_input_tokens,
913 calls = snapshot.total_calls,
914 );
915 if !snapshot.models.is_empty() {
916 eprintln!(" models: {}", snapshot.models.join(", "));
917 }
918 }
919 print_report(per_test, global);
920}
921
922pub fn print_report(per_test: &[(String, UsageSnapshot)], global: &UsageSnapshot) {
924 if per_test.is_empty() {
925 return;
926 }
927
928 eprintln!();
929 eprintln!("-------------------------------");
930 eprintln!(" COST REPORT");
931 eprintln!("-------------------------------");
932
933 for (test_name, snapshot) in per_test {
934 eprintln!(
935 " Test: \"{test_name}\" — ${cost:.4} | {tokens} tokens \
936 ({input} in / {output} out, {cached} cached, {write} cache write) | {calls} calls",
937 cost = snapshot.total_cost,
938 tokens = snapshot.total_tokens,
939 input = snapshot.total_input_tokens,
940 output = snapshot.total_output_tokens,
941 cached = snapshot.total_cached_input_tokens,
942 write = snapshot.total_cache_creation_input_tokens,
943 calls = snapshot.total_calls,
944 );
945 if !snapshot.models.is_empty() {
946 eprintln!(" models: {}", snapshot.models.join(", "));
947 }
948 for (ep_name, ep_usage) in &snapshot.endpoints {
949 if ep_usage.calls == 0 {
950 continue;
951 }
952 eprintln!(
953 " endpoint.{ep_name}: {calls:>3} calls, {input:>7} in / {output:>7} out \
954 ({cached} cached, {write} cache write), {tokens:>7} tokens, ${cost:.4}",
955 calls = ep_usage.calls,
956 input = ep_usage.input_tokens,
957 output = ep_usage.output_tokens,
958 cached = ep_usage.cached_input_tokens,
959 write = ep_usage.cache_creation_input_tokens,
960 tokens = ep_usage.input_tokens + ep_usage.output_tokens,
961 cost = ep_usage.cost,
962 );
963 if !ep_usage.models.is_empty() {
964 eprintln!(
965 " models: {}",
966 ep_usage
967 .models
968 .iter()
969 .cloned()
970 .collect::<Vec<_>>()
971 .join(", ")
972 );
973 }
974 }
975 }
976
977 eprintln!("-------------------------------");
978 eprintln!(" GLOBAL SUMMARY");
979 eprintln!(
980 " Total cost: ${cost:.4}",
981 cost = global.total_cost
982 );
983 eprintln!(
984 " Total tokens: {tokens}",
985 tokens = global.total_tokens
986 );
987 eprintln!(
988 " Total input: {input}",
989 input = global.total_input_tokens
990 );
991 eprintln!(
992 " Total output: {output}",
993 output = global.total_output_tokens
994 );
995 eprintln!(
996 " Total cached input: {cached}",
997 cached = global.total_cached_input_tokens
998 );
999 eprintln!(
1000 " Total cache write: {write}",
1001 write = global.total_cache_creation_input_tokens
1002 );
1003 eprintln!(
1004 " Total calls: {calls}",
1005 calls = global.total_calls
1006 );
1007 eprintln!(
1008 " Models used: {models}",
1009 models = if global.models.is_empty() {
1010 "-".to_owned()
1011 } else {
1012 global.models.join(", ")
1013 }
1014 );
1015 eprintln!("-------------------------------");
1016}
1017
1018pub fn print_budget_warning(message: &str) {
1020 eprintln!(" ! BUDGET WARNING: {message}");
1021}
1022
1023pub fn print_budget_error(message: &str) {
1025 eprintln!(" ✗ BUDGET EXCEEDED: {message}");
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use std::io::Read;
1031 use std::path::PathBuf;
1032
1033 use super::{
1034 escape_data, escape_property, format_duration, format_event, print_batch_report,
1035 print_report, ColorMode, Level, Palette, Reporter,
1036 };
1037 use crate::costs::{EndpointUsage, UsageSnapshot};
1038 use crate::events::{StepStatus, TestEvent};
1039 use std::collections::HashMap;
1040
1041 fn palette() -> Palette {
1042 Palette { enabled: false }
1043 }
1044
1045 fn temp_file(name: &str) -> PathBuf {
1046 let dir = std::env::temp_dir().join(format!("lbt-report-{}", std::process::id()));
1047 std::fs::create_dir_all(&dir).expect("temp dir");
1048 dir.join(name)
1049 }
1050
1051 fn read_file(path: &PathBuf) -> String {
1052 let mut s = String::new();
1053 let mut f = std::fs::File::open(path).expect("open report file");
1054 f.read_to_string(&mut s).expect("read report file");
1055 s
1056 }
1057
1058 fn step_event(status: StepStatus) -> TestEvent {
1059 TestEvent::StepFinished {
1060 test: "login".into(),
1061 index: 0,
1062 label: "[click] sign in".into(),
1063 status,
1064 duration_ms: 1200,
1065 message: if status == StepStatus::Failed {
1066 "element #btn not found".into()
1067 } else {
1068 "clicked #btn".into()
1069 },
1070 diagnostics: (status == StepStatus::Failed).then(|| " | url: http://x".into()),
1071 screenshot: (status == StepStatus::Failed).then(|| "artifacts/login.png".into()),
1072 }
1073 }
1074
1075 #[test]
1076 fn test_level_from_flags() {
1077 assert_eq!(Level::from_flags(0, 0), Level::Info);
1078 assert_eq!(Level::from_flags(0, 1), Level::Debug);
1079 assert_eq!(Level::from_flags(0, 2), Level::Trace);
1080 assert_eq!(Level::from_flags(0, 9), Level::Trace);
1081 assert_eq!(Level::from_flags(1, 0), Level::Warn);
1082 assert_eq!(Level::from_flags(1, 5), Level::Warn, "quiet wins");
1083 assert_eq!(Level::from_flags(2, 0), Level::Error);
1084 assert_eq!(Level::from_flags(3, 0), Level::Error);
1085 }
1086
1087 #[test]
1088 fn test_format_step_finished_failed_includes_diagnostics() {
1089 let (level, text) = format_event(&step_event(StepStatus::Failed), palette());
1090 assert_eq!(level, Level::Error);
1091 assert!(text.contains("[click] sign in"));
1092 assert!(text.contains("element #btn not found"));
1093 assert!(text.contains("1.2s"));
1094 assert!(text.contains("| url: http://x"));
1095 assert!(text.contains("screenshot: artifacts/login.png"));
1096 assert!(!text.contains("\x1b["), "no ANSI codes when color disabled");
1097 }
1098
1099 #[test]
1100 fn test_format_step_finished_passed_level_info() {
1101 let (level, text) = format_event(&step_event(StepStatus::Passed), palette());
1102 assert_eq!(level, Level::Info);
1103 assert!(text.contains("clicked #btn"));
1104 assert!(!text.contains("screenshot:"));
1105 }
1106
1107 #[test]
1108 fn test_format_test_finished_verdict() {
1109 let ok = format_event(
1110 &TestEvent::TestFinished {
1111 test: "t1".into(),
1112 passed: 3,
1113 failed: 0,
1114 skipped: 0,
1115 duration_ms: 6100,
1116 cost: 0.0123,
1117 tokens: 1234,
1118 input_tokens: 800,
1119 output_tokens: 434,
1120 cached_input_tokens: 200,
1121 cache_creation_input_tokens: 50,
1122 models: vec!["deepseek".into()],
1123 calls: 4,
1124 },
1125 palette(),
1126 );
1127 assert_eq!(ok.0, Level::Info);
1128 assert!(ok.1.contains("passed"));
1129 assert!(ok.1.contains("800 in / 434 out"));
1130 assert!(ok.1.contains("50 cache write"));
1131 assert!(ok.1.contains("models: deepseek"));
1132
1133 let bad = format_event(
1134 &TestEvent::TestFinished {
1135 test: "t2".into(),
1136 passed: 1,
1137 failed: 1,
1138 skipped: 2,
1139 duration_ms: 6100,
1140 cost: 0.0123,
1141 tokens: 1234,
1142 input_tokens: 800,
1143 output_tokens: 434,
1144 cached_input_tokens: 0,
1145 cache_creation_input_tokens: 0,
1146 models: Vec::new(),
1147 calls: 4,
1148 },
1149 palette(),
1150 );
1151 assert!(bad.1.contains("failed"));
1152 }
1153
1154 #[test]
1155 fn test_format_llm_call_lines() {
1156 let started = format_event(
1157 &TestEvent::LlmCallStarted {
1158 test: "t".into(),
1159 index: 0,
1160 endpoint: "default".into(),
1161 model: "deepseek".into(),
1162 purpose: "targeting".into(),
1163 },
1164 palette(),
1165 );
1166 assert_eq!(started.0, Level::Debug);
1167 assert!(started.1.contains("llm(targeting): default (deepseek)"));
1168
1169 let failed = format_event(
1170 &TestEvent::LlmCallFinished {
1171 test: "t".into(),
1172 index: 0,
1173 endpoint: "default".into(),
1174 model: "deepseek".into(),
1175 purpose: "targeting".into(),
1176 ok: false,
1177 duration_ms: 900,
1178 input_tokens: 100,
1179 output_tokens: 0,
1180 cached_input_tokens: 30,
1181 cache_creation_input_tokens: 12,
1182 cost: 0.0012,
1183 error: Some("HTTP 429: slow down".into()),
1184 },
1185 palette(),
1186 );
1187 assert_eq!(failed.0, Level::Warn);
1188 assert!(failed.1.contains("failed"));
1189 assert!(failed.1.contains("30 cached"));
1190 assert!(failed.1.contains("12 cache write"));
1191 assert!(failed.1.contains("HTTP 429"));
1192 }
1193
1194 #[test]
1195 fn test_format_run_started_is_debug() {
1196 let (level, text) = format_event(&TestEvent::RunStarted { total_tests: 3 }, palette());
1197 assert_eq!(level, Level::Debug);
1198 assert!(text.contains("3 test(s)"));
1199 }
1200
1201 #[test]
1202 fn test_format_duration() {
1203 assert_eq!(format_duration(0), "0ms");
1204 assert_eq!(format_duration(431), "431ms");
1205 assert_eq!(format_duration(1000), "1.0s");
1206 assert_eq!(format_duration(1234), "1.2s");
1207 }
1208
1209 #[test]
1210 fn test_github_escaping() {
1211 assert_eq!(escape_data("a% b\nc\rd"), "a%25 b%0Ac%0Dd");
1212 assert_eq!(escape_property("a:b,c\n%d"), "a%3Ab%2Cc%0A%25d");
1213 }
1214
1215 #[test]
1216 fn test_reporter_jsonl_emits_typed_timestamped_lines() {
1217 let path = temp_file("run.jsonl");
1218 let reporter = Reporter::new(
1219 Level::Debug,
1220 ColorMode::Never,
1221 Some(&path),
1222 None,
1223 None,
1224 false,
1225 )
1226 .expect("open jsonl");
1227 reporter
1228 .emit(&TestEvent::RunStarted { total_tests: 1 })
1229 .expect("emit");
1230 reporter
1231 .emit(&TestEvent::StepStarted {
1232 test: "login".into(),
1233 index: 0,
1234 label: "[click] sign in".into(),
1235 })
1236 .expect("emit");
1237 reporter
1238 .emit(&step_event(StepStatus::Passed))
1239 .expect("emit");
1240 reporter
1241 .emit(&TestEvent::TestFinished {
1242 test: "login".into(),
1243 passed: 1,
1244 failed: 0,
1245 skipped: 0,
1246 duration_ms: 1200,
1247 cost: 0.001,
1248 tokens: 100,
1249 input_tokens: 70,
1250 output_tokens: 30,
1251 cached_input_tokens: 10,
1252 cache_creation_input_tokens: 4,
1253 models: vec!["deepseek".into()],
1254 calls: 1,
1255 })
1256 .expect("emit");
1257 reporter
1258 .emit(&TestEvent::RunFinished {
1259 tests_passed: 1,
1260 tests_failed: 0,
1261 steps_passed: 1,
1262 steps_failed: 0,
1263 steps_skipped: 0,
1264 total_cost: 0.001,
1265 total_tokens: 100,
1266 total_input_tokens: 70,
1267 total_output_tokens: 30,
1268 total_cached_input_tokens: 10,
1269 total_cache_creation_input_tokens: 4,
1270 models: vec!["deepseek".into()],
1271 total_calls: 1,
1272 })
1273 .expect("emit");
1274 reporter.finish().expect("finish");
1275
1276 let content = read_file(&path);
1277 let lines: Vec<&str> = content.lines().collect();
1278 assert_eq!(lines.len(), 5);
1279 let first: serde_json::Value = serde_json::from_str(lines[0]).expect("json line");
1280 assert_eq!(first["type"], "run_started");
1281 assert!(first["ts"].as_u64().expect("ts") > 0);
1282 let step: serde_json::Value = serde_json::from_str(lines[2]).expect("json line");
1283 assert_eq!(step["type"], "step_finished");
1284 assert_eq!(step["status"], "passed");
1285 assert_eq!(step["test"], "login");
1286 let finished: serde_json::Value = serde_json::from_str(lines[4]).expect("json line");
1287 assert_eq!(finished["type"], "run_finished");
1288 assert_eq!(finished["tests_passed"], 1);
1289 }
1290
1291 #[test]
1292 fn test_reporter_junit_creates_failure_elements() {
1293 let path = temp_file("report.xml");
1294 let reporter = Reporter::new(
1295 Level::Info,
1296 ColorMode::Never,
1297 None,
1298 Some(&path),
1299 None,
1300 false,
1301 )
1302 .expect("open junit");
1303 reporter
1304 .emit(&step_event(StepStatus::Failed))
1305 .expect("emit");
1306 reporter
1307 .emit(&step_event(StepStatus::Skipped))
1308 .expect("emit");
1309 reporter
1310 .emit(&TestEvent::TestFinished {
1311 test: "login".into(),
1312 passed: 0,
1313 failed: 1,
1314 skipped: 1,
1315 duration_ms: 5000,
1316 cost: 0.0,
1317 tokens: 0,
1318 input_tokens: 0,
1319 output_tokens: 0,
1320 cached_input_tokens: 0,
1321 cache_creation_input_tokens: 0,
1322 models: Vec::new(),
1323 calls: 0,
1324 })
1325 .expect("emit");
1326 reporter.finish().expect("finish");
1327
1328 let xml = read_file(&path);
1329 assert!(xml.starts_with("<?xml version=\"1.0\""));
1330 assert!(xml.contains("<testsuites tests=\"1\" failures=\"1\" skipped=\"1\""));
1331 assert!(xml.contains("name=\"login\""));
1332 assert!(xml.contains("<skipped/>"));
1333 assert!(xml.contains("<failure"));
1334 assert!(xml.contains("element #btn not found"));
1335 assert!(xml.contains("screenshot: artifacts/login.png"));
1336 let weird = TestEvent::StepFinished {
1338 test: "weird".into(),
1339 index: 0,
1340 label: "[wait] <weird>".into(),
1341 status: StepStatus::Failed,
1342 duration_ms: 10,
1343 message: "boom <tag> & \"quote\"".into(),
1344 diagnostics: None,
1345 screenshot: None,
1346 };
1347 reporter.emit(&weird).expect("emit");
1348 reporter
1349 .emit(&TestEvent::TestFinished {
1350 test: "weird".into(),
1351 passed: 0,
1352 failed: 1,
1353 skipped: 0,
1354 duration_ms: 10,
1355 cost: 0.0,
1356 tokens: 0,
1357 input_tokens: 0,
1358 output_tokens: 0,
1359 cached_input_tokens: 0,
1360 cache_creation_input_tokens: 0,
1361 models: Vec::new(),
1362 calls: 0,
1363 })
1364 .expect("emit");
1365 reporter.finish().expect("finish");
1366 let xml2 = read_file(&path);
1367 assert!(xml2.contains("<weird>") && xml2.contains("& "quote""));
1369 assert!(xml2.contains("[wait] <weird>: boom <tag> & \"quote\""));
1371 }
1372
1373 #[test]
1374 fn test_reporter_trace_emits_complete_spans() {
1375 let path = temp_file("trace.json");
1376 let reporter = Reporter::new(
1377 Level::Info,
1378 ColorMode::Never,
1379 None,
1380 None,
1381 Some(&path),
1382 false,
1383 )
1384 .expect("open trace");
1385 reporter
1386 .emit(&TestEvent::TestStarted {
1387 test: "login".into(),
1388 })
1389 .expect("emit");
1390 reporter
1391 .emit(&TestEvent::StepStarted {
1392 test: "login".into(),
1393 index: 0,
1394 label: "[click] sign in".into(),
1395 })
1396 .expect("emit");
1397 reporter
1398 .emit(&step_event(StepStatus::Passed))
1399 .expect("emit");
1400 reporter
1401 .emit(&TestEvent::TestFinished {
1402 test: "login".into(),
1403 passed: 1,
1404 failed: 0,
1405 skipped: 0,
1406 duration_ms: 1200,
1407 cost: 0.0,
1408 tokens: 0,
1409 input_tokens: 0,
1410 output_tokens: 0,
1411 cached_input_tokens: 0,
1412 cache_creation_input_tokens: 0,
1413 models: Vec::new(),
1414 calls: 0,
1415 })
1416 .expect("emit");
1417 reporter.finish().expect("finish");
1418
1419 let content = read_file(&path);
1420 let doc: serde_json::Value = serde_json::from_str(&content).expect("trace json");
1421 let events = doc["traceEvents"].as_array().expect("traceEvents array");
1422 assert_eq!(events.len(), 2);
1423 let step = &events[0];
1425 assert_eq!(step["name"], "[click] sign in");
1426 assert_eq!(step["ph"], "X");
1427 assert_eq!(step["cat"], "step");
1428 assert_eq!(events[1]["name"], "login");
1429 assert_eq!(events[1]["cat"], "test");
1430 assert!(step["ts"].as_u64().expect("ts") > 0);
1431 assert_eq!(doc["displayTimeUnit"], "ms");
1432 }
1433
1434 #[test]
1435 fn test_print_report_empty() {
1436 print_report(&[], &UsageSnapshot::default());
1438 }
1439
1440 #[test]
1441 fn test_print_report_single_test() {
1442 let per_test = vec![("test1".to_owned(), make_snapshot(0.05, 500, 3))];
1443 print_report(&per_test, &make_snapshot(0.05, 500, 3));
1445 }
1446
1447 #[test]
1448 fn test_print_batch_report_sums_files() {
1449 let per_file = vec![
1450 ("a.toml".to_owned(), make_snapshot(0.05, 500, 3)),
1451 ("b.toml".to_owned(), make_snapshot(0.07, 700, 4)),
1452 ];
1453 let per_test = vec![("test1".to_owned(), make_snapshot(0.05, 500, 3))];
1454 print_batch_report(&per_file, &per_test, &make_snapshot(0.12, 1200, 7));
1456 print_batch_report(&[], &per_test, &make_snapshot(0.12, 1200, 7));
1458 }
1459
1460 fn make_snapshot(cost: f64, tokens: u64, calls: u64) -> UsageSnapshot {
1461 let mut eps = HashMap::new();
1462 eps.insert(
1463 "default".to_owned(),
1464 EndpointUsage {
1465 calls,
1466 input_tokens: tokens / 2,
1467 output_tokens: tokens / 2,
1468 cached_input_tokens: tokens / 4,
1469 cache_creation_input_tokens: tokens / 8,
1470 cost,
1471 models: std::iter::once("deepseek".to_owned()).collect(),
1472 },
1473 );
1474 UsageSnapshot::from_endpoints(&eps)
1475 }
1476}