feat(cli): show progress bar with elapsed time and ETA

A terminal gets a bar that rewrites in place. A piped log gets a line every 25 tests or every minute.
The line shows the error count when a call has failed.
This commit is contained in:
2026-10-02 01:01:13 +01:00
parent 6934d4df84
commit 7b791a78e7
2 changed files with 184 additions and 50 deletions
+93 -37
View File
@@ -2,10 +2,12 @@ package main
import (
"bytes"
"errors"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/runner"
@@ -24,9 +26,26 @@ func TestStderrProgressReporter_ReportStart(t *testing.T) {
}
}
// fakeClock advances only when told, so elapsed/ETA are exact.
type fakeClock struct{ t time.Time }
func (c *fakeClock) now() time.Time { return c.t }
func newTestReporter(buf *bytes.Buffer, tty bool) (*stderrProgressReporter, *fakeClock) {
clk := &fakeClock{t: time.Unix(1_000_000, 0)}
r := newStderrProgressReporter(buf)
r.isTTY = tty
r.now = clk.now
r.ReportStart(10, 1, 1)
buf.Reset()
return r, clk
}
var okResult = runner.Result{Model: "m", TestID: "t", Score: eval.Score{Value: 1}}
// A bytes.Buffer is not a character device, so newStderrProgressReporter
// selects piped mode: a plain line every pipedProgressEvery completions
// plus the final one, nothing else.
// (or pipedProgressInterval), plus the final one.
func TestStderrProgressReporter_ReportDone_Piped(t *testing.T) {
tests := []struct {
name string
@@ -35,17 +54,16 @@ func TestStderrProgressReporter_ReportDone_Piped(t *testing.T) {
total int
}{
{name: "intermediate completion stays silent", done: 3, total: 10, want: ""},
{name: "final completion always prints", done: 10, total: 10, want: "test 10 of 10\n"},
{name: "every pipedProgressEvery-th completion prints", done: pipedProgressEvery, total: 100, want: "test 25 of 100\n"},
{name: "multiple of pipedProgressEvery prints", done: 2 * pipedProgressEvery, total: 100, want: "test 50 of 100\n"},
{name: "final completion always prints", done: 10, total: 10, want: "100% 10/10 elapsed 10s\n"},
{name: "every pipedProgressEvery-th completion prints", done: pipedProgressEvery, total: 100, want: " 25% 25/100 elapsed 10s ETA ~30s\n"},
{name: "off-interval completion stays silent", done: 2*pipedProgressEvery + 1, total: 100, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
r := newStderrProgressReporter(&buf)
r.ReportDone(tt.done, tt.total, runner.Result{Model: "m", TestID: "t", Score: eval.Score{Value: 1}})
r, clk := newTestReporter(&buf, false)
clk.t = clk.t.Add(10 * time.Second)
r.ReportDone(tt.done, tt.total, okResult)
if got := buf.String(); got != tt.want {
t.Errorf("ReportDone(%d, %d) output = %q, want %q", tt.done, tt.total, got, tt.want)
}
@@ -53,39 +71,77 @@ func TestStderrProgressReporter_ReportDone_Piped(t *testing.T) {
}
}
// TTY mode rewrites one counter line in place; the test forces isTTY since
// a bytes.Buffer can never be a terminal.
func TestStderrProgressReporter_ReportDone_PipedTimeBasedLine(t *testing.T) {
var buf bytes.Buffer
r, clk := newTestReporter(&buf, false)
clk.t = clk.t.Add(pipedProgressInterval - time.Second)
r.ReportDone(1, 10, okResult)
if buf.Len() != 0 {
t.Fatalf("printed before the interval: %q", buf.String())
}
clk.t = clk.t.Add(2 * time.Second)
r.ReportDone(2, 10, okResult)
if !strings.Contains(buf.String(), "2/10") {
t.Errorf("no time-based line after the interval, got %q", buf.String())
}
}
// TTY mode rewrites one bar line in place; the test forces isTTY since a
// bytes.Buffer can never be a terminal.
func TestStderrProgressReporter_ReportDone_TTY(t *testing.T) {
tests := []struct {
name string
want string
done int
total int
}{
{name: "intermediate rewrites in place without a newline", done: 3, total: 10, want: "\rtest 3 of 10\x1b[K"},
{name: "final completion ends the line", done: 10, total: 10, want: "\rtest 10 of 10\x1b[K\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
r := newStderrProgressReporter(&buf)
r.isTTY = true
r.ReportDone(tt.done, tt.total, runner.Result{Model: "m", TestID: "t", Score: eval.Score{Value: 1}})
var buf bytes.Buffer
r, clk := newTestReporter(&buf, true)
clk.t = clk.t.Add(30 * time.Second)
if got := buf.String(); got != tt.want {
t.Errorf("ReportDone(%d, %d) output = %q, want %q", tt.done, tt.total, got, tt.want)
}
})
r.ReportDone(5, 10, okResult)
want := "\r" + strings.Repeat(barFilled, 15) + strings.Repeat(barEmpty, 15) + " 50% 5/10 elapsed 30s ETA ~30s\x1b[K"
if got := buf.String(); got != want {
t.Errorf("mid-run output = %q, want %q", got, want)
}
buf.Reset()
r.ReportDone(10, 10, okResult)
want = "\r" + strings.Repeat(barFilled, barWidth) + " 100% 10/10 elapsed 30s\x1b[K\n"
if got := buf.String(); got != want {
t.Errorf("final output = %q, want %q (full bar, no ETA, line ended)", got, want)
}
}
// TestStderrProgressReporter_ReportDone_ConcurrencySafe writes many
// concurrent ReportDone calls (as Runner.Run's goroutines would) and
// verifies no write interleaved mid-line - the reporter's own mutex must
// serialize writes, not just the counter.
func TestStderrProgressReporter_CountsErrors(t *testing.T) {
var buf bytes.Buffer
r, _ := newTestReporter(&buf, false)
r.ReportDone(1, 2, runner.Result{Err: errors.New("boom")})
r.ReportDone(2, 2, okResult)
if got := buf.String(); !strings.Contains(got, "errors 1") {
t.Errorf("output = %q, want an errors count of 1", got)
}
}
func TestFormatDuration(t *testing.T) {
tests := map[time.Duration]string{
0: "0s",
44*time.Second + 600*time.Millisecond: "45s",
12*time.Minute + 4*time.Second: "12m04s",
time.Hour + 2*time.Minute + 30*time.Second: "1h02m",
}
for in, want := range tests {
if got := formatDuration(in); got != want {
t.Errorf("formatDuration(%v) = %q, want %q", in, got, want)
}
}
}
func TestFormatETA_NoDataYet(t *testing.T) {
if got := formatETA(0, 10, time.Minute); got != "--" {
t.Errorf("formatETA with nothing done = %q, want --", got)
}
}
// Concurrent ReportDone calls (as Runner.Run's goroutines would) must not
// interleave mid-line: the reporter's own mutex serializes the writes.
func TestStderrProgressReporter_ReportDone_ConcurrencySafe(t *testing.T) {
var buf bytes.Buffer
r := newStderrProgressReporter(&buf)
r, _ := newTestReporter(&buf, false)
const n = 2 * pipedProgressEvery
var wg sync.WaitGroup
@@ -93,18 +149,18 @@ func TestStderrProgressReporter_ReportDone_ConcurrencySafe(t *testing.T) {
wg.Add(1)
go func(i int) {
defer wg.Done()
r.ReportDone(i, n, runner.Result{Model: "m", TestID: "t", Score: eval.Score{Value: 1}})
r.ReportDone(i, n, okResult)
}(i)
}
wg.Wait()
// In piped mode exactly the 25th and 50th completions print.
// Completion order is arbitrary, so the 25th and 50th calls to arrive
// print: exactly two lines.
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("got %d lines (%q), want 2 (a corrupted/interleaved write would change the count)", len(lines), buf.String())
}
linePattern := regexp.MustCompile(`^test \d+ of \d+$`)
linePattern := regexp.MustCompile(`^\s*\d+% \d+/\d+ elapsed \d+s( ETA ~\d+s)?$`)
for _, line := range lines {
if !linePattern.MatchString(line) {
t.Errorf("malformed or interleaved line: %q", line)