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