a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
328 lines
9.9 KiB
Go
328 lines
9.9 KiB
Go
package progress
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/x/ansi"
|
|
)
|
|
|
|
// forceUnicode pins the glyph set so assertions are stable regardless of the
|
|
// host environment.
|
|
func forceUnicode(t *testing.T) {
|
|
t.Helper()
|
|
t.Setenv("GORTEX_ASCII", "")
|
|
t.Setenv("GORTEX_UNICODE", "1")
|
|
}
|
|
|
|
// newAnimatedTestTracker returns a tracker that renders the animated protocol
|
|
// into buf: animation forced (no TTY in tests), the frame ticker frozen so
|
|
// paints happen only at Start / Logf / finish, and a fixed terminal size.
|
|
func newAnimatedTestTracker(t *testing.T, buf *bytes.Buffer, w, h int, opts ...TrackerOption) *Tracker {
|
|
t.Helper()
|
|
t.Setenv("GORTEX_FORCE_ANIMATION", "1")
|
|
tr := NewTracker(buf, opts...)
|
|
tr.fps = time.Hour
|
|
tr.sizeFn = func() (int, int) { return w, h }
|
|
return tr
|
|
}
|
|
|
|
func TestTrackerPlainExplicitStepsLifecycle(t *testing.T) {
|
|
forceUnicode(t)
|
|
var buf bytes.Buffer
|
|
tr := NewTracker(&buf, WithoutAnimation())
|
|
tr.Start("gortex init")
|
|
|
|
scan := tr.AddStep("scan repository")
|
|
parse := tr.AddStep("parse sources")
|
|
analyze := tr.AddStep("analyze codebase")
|
|
broken := tr.AddStep("configure adapters")
|
|
if strings.Contains(buf.String(), "scan repository") {
|
|
t.Fatalf("pending steps must stay silent in plain mode, got:\n%s", buf.String())
|
|
}
|
|
|
|
tr.StartStep("scan repository")
|
|
scan.Progress(1338, 0)
|
|
scan.SetUnit("files")
|
|
scan.DoneAs("files discovered")
|
|
|
|
tr.StartStep("parse sources")
|
|
parse.Progress(48210, 0)
|
|
parse.SetUnit("symbols")
|
|
parse.Done()
|
|
|
|
analyze.Skip("disabled by flag")
|
|
|
|
tr.StartStep("configure adapters")
|
|
broken.Fail(errors.New("no adapters selected"))
|
|
|
|
tr.Done("ready", "indexed 1,338 files · 48,210 symbols")
|
|
|
|
out := buf.String()
|
|
wants := []string{
|
|
" gortex init",
|
|
" scan repository ...",
|
|
" ✓ files discovered · 1,338 files",
|
|
" parse sources ...",
|
|
" ✓ parse sources · 48,210 symbols",
|
|
" · analyze codebase — skipped: disabled by flag",
|
|
" configure adapters ...",
|
|
" ✗ configure adapters: no adapters selected",
|
|
" ✓ ready — indexed 1,338 files · 48,210 symbols",
|
|
}
|
|
for _, w := range wants {
|
|
if !strings.Contains(out, w) {
|
|
t.Errorf("plain output missing %q\n--- got ---\n%s", w, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTrackerPlainHeartbeatThrottled(t *testing.T) {
|
|
forceUnicode(t)
|
|
var buf bytes.Buffer
|
|
tr := NewTracker(&buf, WithoutAnimation())
|
|
clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
tr.now = func() time.Time { return clock }
|
|
tr.Start("indexing")
|
|
|
|
s := tr.StartStep("resolving references")
|
|
s.Progress(1, 10)
|
|
s.Progress(2, 10) // within the throttle window — silent
|
|
if got := strings.Count(buf.String(), "resolving references"); got != 1 {
|
|
t.Fatalf("ticks inside the throttle window must not print, got %d occurrences:\n%s", got, buf.String())
|
|
}
|
|
|
|
clock = clock.Add(11 * time.Second)
|
|
s.Progress(5, 10)
|
|
if !strings.Contains(buf.String(), " resolving references · 5 / 10 (11s)") {
|
|
t.Errorf("expected a heartbeat line after the throttle window, got:\n%s", buf.String())
|
|
}
|
|
|
|
s.Progress(6, 10) // window reset — silent again
|
|
if got := strings.Count(buf.String(), "resolving references"); got != 2 {
|
|
t.Errorf("heartbeat must rearm the throttle, got %d occurrences:\n%s", got, buf.String())
|
|
}
|
|
}
|
|
|
|
func TestTrackerAnimatedFrameProtocol(t *testing.T) {
|
|
forceUnicode(t)
|
|
var buf bytes.Buffer
|
|
tr := newAnimatedTestTracker(t, &buf, 80, 24)
|
|
tr.Start("gortex enrich")
|
|
|
|
s := tr.StartStep("stamping blame")
|
|
s.Progress(120, 400)
|
|
s.SetUnit("nodes")
|
|
s.Done()
|
|
tr.Done("", "400 nodes stamped")
|
|
|
|
out := buf.String()
|
|
if !strings.HasPrefix(out, ansiHideCursor) {
|
|
t.Errorf("animated stream must open by hiding the cursor, got prefix %q", out[:min(20, len(out))])
|
|
}
|
|
if !strings.Contains(out, ansiSyncStart) || !strings.Contains(out, ansiSyncEnd) {
|
|
t.Error("frames must be wrapped in synchronized-update brackets")
|
|
}
|
|
if strings.Count(out, ansiSyncStart) != strings.Count(out, ansiSyncEnd) {
|
|
t.Error("unbalanced synchronized-update brackets")
|
|
}
|
|
if !strings.Contains(out, ansiShowCursor) {
|
|
t.Error("final frame must re-show the cursor")
|
|
}
|
|
if !strings.Contains(out, ansiReset) {
|
|
t.Error("final frame must reset SGR state")
|
|
}
|
|
// The final frame carries the completed checklist and the summary.
|
|
for _, w := range []string{"✓ stamping blame", "400 nodes stamped", "gortex enrich"} {
|
|
if !strings.Contains(ansi.Strip(out), w) {
|
|
t.Errorf("final frame missing %q\n--- stripped ---\n%s", w, ansi.Strip(out))
|
|
}
|
|
}
|
|
// Every frame line terminates with clear-to-EOL + CRLF so repaints can
|
|
// never leave residue and the cursor arithmetic stays exact.
|
|
frames := strings.Split(out, ansiSyncStart)
|
|
for _, fr := range frames[1:] {
|
|
body := strings.TrimSuffix(fr, ansiSyncEnd)
|
|
for _, ln := range strings.Split(body, "\r\n") {
|
|
if strings.Contains(ln, "\n") {
|
|
t.Errorf("bare LF inside a frame line: %q", ln)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTrackerAnimatedLinesNeverExceedWidth(t *testing.T) {
|
|
forceUnicode(t)
|
|
const width = 34
|
|
var buf bytes.Buffer
|
|
tr := newAnimatedTestTracker(t, &buf, width, 24)
|
|
tr.Start("gortex enrich with an extremely long command title that cannot fit")
|
|
|
|
s := tr.StartStep("a step label that is much longer than the terminal width for sure")
|
|
s.Progress(123456, 654321)
|
|
s.SetUnit("symbols")
|
|
s.Note("annotation that also overflows the width")
|
|
tr.Done("", "")
|
|
|
|
for _, raw := range strings.Split(buf.String(), "\r\n") {
|
|
if w := ansi.StringWidth(raw); w > width-1 {
|
|
t.Errorf("frame line exceeds clamp width %d (got %d): %q", width-1, w, ansi.Strip(raw))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTrackerOverflowRetiresFinishedRows(t *testing.T) {
|
|
forceUnicode(t)
|
|
var buf bytes.Buffer
|
|
tr := newAnimatedTestTracker(t, &buf, 80, 8) // maxRegion = 6
|
|
tr.Start("bulk run")
|
|
for i := 0; i < 10; i++ {
|
|
s := tr.StartStep(fmt.Sprintf("phase-%02d", i))
|
|
s.Done()
|
|
}
|
|
tr.Done("", "")
|
|
|
|
stripped := ansi.Strip(buf.String())
|
|
for i := 0; i < 10; i++ {
|
|
label := fmt.Sprintf("phase-%02d", i)
|
|
if !strings.Contains(stripped, label) {
|
|
t.Errorf("finished step %s lost during overflow handling\n--- got ---\n%s", label, stripped)
|
|
}
|
|
}
|
|
// The final live region must fit the clamped height. Region lines are the
|
|
// ones terminated by clear-to-EOL; retired rows above the region are
|
|
// written without it.
|
|
frames := strings.Split(buf.String(), ansiSyncStart)
|
|
last := frames[len(frames)-1]
|
|
if lines := strings.Count(last, ansiClearEOL); lines > 6 {
|
|
t.Errorf("final live region paints %d lines, exceeding the clamp of 6", lines)
|
|
}
|
|
}
|
|
|
|
func TestTrackerLogfWritesAboveRegion(t *testing.T) {
|
|
forceUnicode(t)
|
|
var buf bytes.Buffer
|
|
tr := newAnimatedTestTracker(t, &buf, 80, 24)
|
|
tr.Start("watching")
|
|
tr.Logf("warning: adapter %s misbehaved", "codex")
|
|
tr.Done("", "")
|
|
|
|
out := ansi.Strip(buf.String())
|
|
warnIdx := strings.Index(out, "warning: adapter codex misbehaved")
|
|
if warnIdx < 0 {
|
|
t.Fatalf("log line missing from animated stream:\n%s", out)
|
|
}
|
|
// The region is repainted after the log line, so the title must appear
|
|
// again later in the stream.
|
|
if !strings.Contains(out[warnIdx:], "watching") {
|
|
t.Errorf("live region not repainted after log line:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestTrackerReporterBuildsChecklistAnimated(t *testing.T) {
|
|
forceUnicode(t)
|
|
var buf bytes.Buffer
|
|
tr := newAnimatedTestTracker(t, &buf, 100, 24)
|
|
tr.Start("indexing repository")
|
|
tr.Report("discovering files", 0, 0)
|
|
tr.Report("discovering files", 900, 0)
|
|
tr.Report("parsing", 10, 900)
|
|
tr.Report("parsing", 900, 900)
|
|
tr.Done("", "")
|
|
|
|
out := ansi.Strip(buf.String())
|
|
if !strings.Contains(out, "✓ discovering files · 900") {
|
|
t.Errorf("previous stage must complete with its final counter:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "✓ parsing · 900") {
|
|
t.Errorf("last stage must be completed by Done:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestTrackerBannerPresetRendersLogoAndStatus(t *testing.T) {
|
|
forceUnicode(t)
|
|
var buf bytes.Buffer
|
|
tr := newAnimatedTestTracker(t, &buf, 100, 30, WithLogo())
|
|
tr.Start("gortex init")
|
|
tr.AddStep("index repository")
|
|
tr.AddStep("configure adapters")
|
|
tr.StartStep("index repository").Done()
|
|
tr.SetStatus("configuring")
|
|
tr.StartStep("configure adapters").Done()
|
|
tr.Done("ready", "indexed everything")
|
|
|
|
out := ansi.Strip(buf.String())
|
|
if got := strings.Count(out, "●"); got < 10 {
|
|
t.Errorf("banner preset must render the dot-matrix mark, found %d dots:\n%s", got, out)
|
|
}
|
|
for _, w := range []string{"gortex init", "ready", "indexed everything", "✓ index repository", "✓ configure adapters"} {
|
|
if !strings.Contains(out, w) {
|
|
t.Errorf("banner output missing %q:\n%s", w, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTrackerConcurrentReportsAreSafe(t *testing.T) {
|
|
forceUnicode(t)
|
|
t.Setenv("GORTEX_FORCE_ANIMATION", "1")
|
|
var buf bytes.Buffer
|
|
tr := NewTracker(&buf)
|
|
tr.fps = 2 * time.Millisecond
|
|
tr.sizeFn = func() (int, int) { return 80, 24 }
|
|
tr.Start("stress")
|
|
|
|
var wg sync.WaitGroup
|
|
for g := 0; g < 8; g++ {
|
|
wg.Add(1)
|
|
go func(g int) {
|
|
defer wg.Done()
|
|
for i := 0; i < 200; i++ {
|
|
tr.Report(fmt.Sprintf("stage-%d", g%3), i, 200)
|
|
}
|
|
}(g)
|
|
}
|
|
wg.Wait()
|
|
time.Sleep(10 * time.Millisecond) // let the ticker paint at least once
|
|
tr.Done("", "")
|
|
if !strings.Contains(ansi.Strip(buf.String()), "stress") {
|
|
t.Error("expected output from the concurrent run")
|
|
}
|
|
}
|
|
|
|
func TestTrackerFinishIdempotent(t *testing.T) {
|
|
forceUnicode(t)
|
|
var buf bytes.Buffer
|
|
tr := NewTracker(&buf, WithoutAnimation())
|
|
tr.Start("run")
|
|
tr.Done("", "")
|
|
tr.Done("", "")
|
|
tr.Fail(errors.New("late"))
|
|
|
|
out := buf.String()
|
|
if got := strings.Count(out, "✓"); got != 1 {
|
|
t.Errorf("expected exactly one ✓, got %d:\n%s", got, out)
|
|
}
|
|
if strings.Contains(out, "✗") {
|
|
t.Errorf("Fail after Done must be ignored:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestSpinnerCompatStatusRidesFinishLine(t *testing.T) {
|
|
forceUnicode(t)
|
|
var buf bytes.Buffer
|
|
sp := NewSpinner(&buf)
|
|
sp.Disable()
|
|
sp.Start("Enriched via daemon")
|
|
sp.Set("", "42 nodes stamped")
|
|
sp.Done()
|
|
|
|
if !strings.Contains(buf.String(), "✓ Enriched via daemon — 42 nodes stamped") {
|
|
t.Errorf("finish line must carry the last status, got:\n%s", buf.String())
|
|
}
|
|
}
|