mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
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:
+91
-13
@@ -4,7 +4,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/runner"
|
||||
)
|
||||
@@ -13,24 +15,40 @@ import (
|
||||
// line. A piped log stays readable without one line per request.
|
||||
const pipedProgressEvery = 25
|
||||
|
||||
// pipedProgressInterval also forces a piped line after this much time, so
|
||||
// a run of slow models still logs progress between the count-based lines.
|
||||
const pipedProgressInterval = time.Minute
|
||||
|
||||
// barWidth is the number of cells in the terminal progress bar.
|
||||
const barWidth = 30
|
||||
|
||||
const (
|
||||
barFilled = "\u2588"
|
||||
barEmpty = "\u2591"
|
||||
)
|
||||
|
||||
// stderrProgressReporter implements runner.ProgressReporter, writing a
|
||||
// self-updating "test N of M" counter to w (os.Stderr in production).
|
||||
// progress bar with elapsed time and ETA to w (os.Stderr in production).
|
||||
// Run's stdout stays pipeable (table/markdown/json only): all progress
|
||||
// goes here instead.
|
||||
//
|
||||
// When w is a terminal the counter rewrites itself in place with a
|
||||
// carriage return. When w is piped (a log file), rewriting would produce
|
||||
// one unreadable blob, so it prints a plain line every pipedProgressEvery
|
||||
// completions plus the final one.
|
||||
// When w is a terminal the bar rewrites itself in place with a carriage
|
||||
// return. When w is piped (a log file), rewriting would produce one
|
||||
// unreadable blob, so it prints a plain line every pipedProgressEvery
|
||||
// completions or pipedProgressInterval, plus the final one.
|
||||
type stderrProgressReporter struct {
|
||||
w io.Writer
|
||||
mu sync.Mutex
|
||||
isTTY bool
|
||||
w io.Writer
|
||||
now func() time.Time
|
||||
start time.Time
|
||||
lastPrint time.Time
|
||||
mu sync.Mutex
|
||||
errors int
|
||||
isTTY bool
|
||||
}
|
||||
|
||||
// newStderrProgressReporter builds a stderrProgressReporter writing to w.
|
||||
func newStderrProgressReporter(w io.Writer) *stderrProgressReporter {
|
||||
return &stderrProgressReporter{w: w, isTTY: isTerminal(w)}
|
||||
return &stderrProgressReporter{w: w, isTTY: isTerminal(w), now: time.Now}
|
||||
}
|
||||
|
||||
// isTerminal reports whether w is a character device (an interactive
|
||||
@@ -51,6 +69,8 @@ func isTerminal(w io.Writer) bool {
|
||||
func (r *stderrProgressReporter) ReportStart(totalTests, totalModels, concurrency int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.start = r.now()
|
||||
r.lastPrint = r.start
|
||||
// A write failure on this side-channel progress stream (a closed pipe,
|
||||
// a full/broken stderr) is not actionable and must not abort the run
|
||||
// that produces the actual report on stdout.
|
||||
@@ -61,22 +81,80 @@ func (r *stderrProgressReporter) ReportStart(totalTests, totalModels, concurrenc
|
||||
// ReportDone implements runner.ProgressReporter. Safe for concurrent calls
|
||||
// from up to Config.Concurrency goroutines at once: the mutex serializes
|
||||
// writes so concurrent completions cannot interleave mid-line.
|
||||
func (r *stderrProgressReporter) ReportDone(done, total int, _ runner.Result) {
|
||||
func (r *stderrProgressReporter) ReportDone(done, total int, res runner.Result) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if res.Err != nil {
|
||||
r.errors++
|
||||
}
|
||||
now := r.now()
|
||||
elapsed := now.Sub(r.start)
|
||||
|
||||
if r.isTTY {
|
||||
// \r returns to the line start, \x1b[K clears any longer previous
|
||||
// text; the final completion ends the line so later output starts
|
||||
// clean.
|
||||
_, _ = fmt.Fprintf(r.w, "\rtest %d of %d\x1b[K", done, total)
|
||||
_, _ = fmt.Fprintf(r.w, "\r%s\x1b[K", r.statusLine(done, total, elapsed, true))
|
||||
if done == total {
|
||||
_, _ = fmt.Fprintln(r.w)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if done%pipedProgressEvery == 0 || done == total {
|
||||
_, _ = fmt.Fprintf(r.w, "test %d of %d\n", done, total)
|
||||
if done%pipedProgressEvery == 0 || done == total || now.Sub(r.lastPrint) >= pipedProgressInterval {
|
||||
r.lastPrint = now
|
||||
_, _ = fmt.Fprintln(r.w, r.statusLine(done, total, elapsed, false))
|
||||
}
|
||||
}
|
||||
|
||||
// statusLine renders the progress text; withBar adds the bar for terminals.
|
||||
func (r *stderrProgressReporter) statusLine(done, total int, elapsed time.Duration, withBar bool) string {
|
||||
pct := 0
|
||||
if total > 0 {
|
||||
pct = done * 100 / total
|
||||
}
|
||||
var b strings.Builder
|
||||
if withBar {
|
||||
filled := 0
|
||||
if total > 0 {
|
||||
filled = done * barWidth / total
|
||||
}
|
||||
b.WriteString(strings.Repeat(barFilled, filled) + strings.Repeat(barEmpty, barWidth-filled) + " ")
|
||||
}
|
||||
fmt.Fprintf(&b, "%3d%% %d/%d elapsed %s", pct, done, total, formatDuration(elapsed))
|
||||
if done < total {
|
||||
fmt.Fprintf(&b, " ETA %s", formatETA(done, total, elapsed))
|
||||
}
|
||||
if r.errors > 0 {
|
||||
fmt.Fprintf(&b, " errors %d", r.errors)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// formatETA extrapolates the remaining time from the average completion
|
||||
// rate so far; "~" marks it as an estimate and "--" means no data yet.
|
||||
// Jobs vary widely in duration, so it is rough, especially early on.
|
||||
func formatETA(done, total int, elapsed time.Duration) string {
|
||||
if done <= 0 {
|
||||
return "--"
|
||||
}
|
||||
remaining := time.Duration(float64(elapsed) / float64(done) * float64(total-done))
|
||||
return "~" + formatDuration(remaining)
|
||||
}
|
||||
|
||||
// formatDuration renders d rounded to seconds as 45s, 12m04s or 1h02m.
|
||||
func formatDuration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
h := int(d / time.Hour)
|
||||
m := int(d % time.Hour / time.Minute)
|
||||
sec := int(d % time.Minute / time.Second)
|
||||
switch {
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%dh%02dm", h, m)
|
||||
case m > 0:
|
||||
return fmt.Sprintf("%dm%02ds", m, sec)
|
||||
default:
|
||||
return fmt.Sprintf("%ds", sec)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user