check / check (push) Successful in 1m6s
When stderr is not a terminal, each phase prints its zero-state line as it starts instead of after its first item. stderrIsTTY uses term.IsTerminal from golang.org/x/term, now a direct dependency, so /dev/null is no longer taken for a terminal. A spinner keeps the library's background redraw, so its count and elapsed time stay current while a phase waits for its next item. A warning printed during a spinner phase goes through the bar (progressbar.Bprintln), which prints it before its next redraw instead of racing it. Bars with a total have no background redraw and still print warnings directly. Model: opus-5-5
187 lines
4.3 KiB
Go
187 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// spinnerIdle comfortably outlasts the 100ms interval at which the
|
|
// progressbar library redraws a spinner from its own goroutine.
|
|
const spinnerIdle = 500 * time.Millisecond
|
|
|
|
// captureStderr points os.Stderr at a file for the rest of the test and
|
|
// returns a function reading back everything written to it.
|
|
func captureStderr(t *testing.T) func() string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), "stderr")
|
|
|
|
f, err := os.Create(path) //nolint:gosec // test-controlled path
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
saved := os.Stderr
|
|
os.Stderr = f
|
|
|
|
t.Cleanup(func() {
|
|
os.Stderr = saved
|
|
|
|
_ = f.Close()
|
|
})
|
|
|
|
return func() string {
|
|
b, err := os.ReadFile(path) //nolint:gosec // test-controlled path
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return string(b)
|
|
}
|
|
}
|
|
|
|
//nolint:paralleltest // replaces the process-wide os.Stderr
|
|
func TestStderrIsTTYFalseForNonTerminals(t *testing.T) {
|
|
r, pipe, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
regular, err := os.Create(filepath.Join(t.TempDir(), "stderr"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
saved := os.Stderr
|
|
|
|
t.Cleanup(func() {
|
|
os.Stderr = saved
|
|
|
|
for _, f := range []*os.File{r, pipe, regular, devNull} {
|
|
_ = f.Close()
|
|
}
|
|
})
|
|
|
|
cases := map[string]*os.File{
|
|
"a pipe": pipe,
|
|
"a regular file": regular,
|
|
os.DevNull: devNull,
|
|
}
|
|
|
|
for name, f := range cases {
|
|
os.Stderr = f
|
|
|
|
if stderrIsTTY() {
|
|
t.Errorf("stderrIsTTY() = true with stderr on %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNewProgressPrintsBeforeFirstItem checks that each pass shows its
|
|
// zero state the moment it starts when stderr is not a terminal, and
|
|
// that the next line still waits for plainInterval.
|
|
//
|
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
|
func TestNewProgressPrintsBeforeFirstItem(t *testing.T) {
|
|
stderr := captureStderr(t)
|
|
|
|
newProgress("walk", -1).increment()
|
|
newProgress("hash", 10).increment()
|
|
|
|
want := "walk: 0 files, elapsed 0s\n" +
|
|
"hash: [0/10] 0% 0 files/s elapsed 0s eta ?\n"
|
|
if got := stderr(); got != want {
|
|
t.Errorf("stderr = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// newWalkSpinner returns the walk pass's terminal display, writing to
|
|
// os.Stderr whether or not it is a terminal, and stops the library's
|
|
// redraws when the test ends.
|
|
func newWalkSpinner(t *testing.T) *progress {
|
|
t.Helper()
|
|
|
|
p := &progress{
|
|
label: "walk", total: -1, start: time.Now(),
|
|
bar: newBar("walk", -1),
|
|
}
|
|
t.Cleanup(p.finish)
|
|
|
|
return p
|
|
}
|
|
|
|
// TestProgressWarningsOnOwnLines drives the terminal display of the walk
|
|
// pass: every warning must land on a line of its own, never inside a
|
|
// redraw of the spinner.
|
|
//
|
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
|
func TestProgressWarningsOnOwnLines(t *testing.T) {
|
|
stderr := captureStderr(t)
|
|
p := newWalkSpinner(t)
|
|
|
|
for _, w := range []string{"first", "second", "third"} {
|
|
p.increment()
|
|
p.warnf("warning %s", w)
|
|
}
|
|
|
|
p.increment()
|
|
|
|
// The spinner prints the warnings at its next redraw.
|
|
time.Sleep(spinnerIdle)
|
|
|
|
// A terminal shows each line as the text after its last carriage
|
|
// return.
|
|
var warnings []string
|
|
|
|
for line := range strings.SplitSeq(stderr(), "\n") {
|
|
if strings.Contains(line, "warning") {
|
|
warnings = append(warnings, line[strings.LastIndex(line, "\r")+1:])
|
|
}
|
|
}
|
|
|
|
want := []string{"warning first", "warning second", "warning third"}
|
|
if !slices.Equal(warnings, want) {
|
|
t.Errorf("warning lines = %q, want %q", warnings, want)
|
|
}
|
|
}
|
|
|
|
// TestSpinnerShowsCountAfterBurst checks that once a burst of items
|
|
// faster than the redraw limit is over, the walk display shows every
|
|
// item completed while it waits for the next one.
|
|
//
|
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
|
func TestSpinnerShowsCountAfterBurst(t *testing.T) {
|
|
stderr := captureStderr(t)
|
|
p := newWalkSpinner(t)
|
|
|
|
for range 50 {
|
|
p.increment()
|
|
}
|
|
|
|
time.Sleep(spinnerIdle)
|
|
|
|
// A terminal shows the last frame drawn. The library starts each
|
|
// frame with a carriage return and erases the previous one with
|
|
// spaces first.
|
|
var shown string
|
|
|
|
for frame := range strings.SplitSeq(stderr(), "\r") {
|
|
if strings.TrimSpace(frame) != "" {
|
|
shown = frame
|
|
}
|
|
}
|
|
|
|
if !strings.Contains(shown, "(50/-,") {
|
|
t.Errorf("terminal shows %q, want a count of 50", shown)
|
|
}
|
|
}
|