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, 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
146struct 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
165struct OpenSpan {
167 kind: u8,
168 name: String,
169 start: Instant,
170}
171
172struct 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
184fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
186 mutex
187 .lock()
188 .unwrap_or_else(std::sync::PoisonError::into_inner)
189}
190
191pub 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 #[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 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 pub fn add_redaction_secret(&self, value: &str) {
272 lock(&self.redactor).add_secret(value, 0);
273 }
274
275 pub fn add_redaction_secrets(&self, values: impl IntoIterator<Item = String>) {
278 lock(&self.redactor).add_secret_values(values);
279 }
280
281 pub fn info(&self, msg: impl AsRef<str>) {
283 self.line(Level::Info, msg.as_ref());
284 }
285
286 pub fn debug(&self, msg: impl AsRef<str>) {
288 self.line(Level::Debug, msg.as_ref());
289 }
290
291 pub fn warn(&self, msg: impl AsRef<str>) {
293 self.line(Level::Warn, &format!(" ! {}", msg.as_ref()));
294 }
295
296 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 #[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 #[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 #[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 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
571fn 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#[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#[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
748fn 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#[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#[must_use]
770fn escape_data(s: &str) -> String {
771 s.replace('%', "%25")
772 .replace('\r', "%0D")
773 .replace('\n', "%0A")
774}
775
776#[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#[must_use]
788fn esc_attr(s: &str) -> String {
789 s.replace('&', "&")
790 .replace('<', "<")
791 .replace('>', ">")
792 .replace('"', """)
793 .replace('\'', "'")
794}
795
796fn 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
841pub 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
880pub fn print_budget_warning(message: &str) {
882 eprintln!(" ! BUDGET WARNING: {message}");
883}
884
885pub 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 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 assert!(xml2.contains("<weird>") && xml2.contains("& "quote""));
1194 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 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 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 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}