Print progress at once off a terminal, keep stderr to one writer (closes #13)
check / check (push) Successful in 1m25s
check / check (push) Successful in 1m25s
On a pipe, a file or /dev/null each scan phase now prints its zero-state line the moment it starts instead of after its first finished item, so a slow first read under cron no longer leaves the log silent. Terminal detection uses golang.org/x/term, now a direct dependency listed in the README; the old character-device test took /dev/null for a terminal. The walk spinner no longer redraws from the library's own goroutine; it advances only as items complete, so a redraw cannot garble a warning. Building the bar moved into newBar so the terminal display can be tested without a terminal. Lint suppressed: gosec on two test file opens, paralleltest on three tests that replace os.Stderr. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// spinnerIdle outlasts the 100ms interval at which the progressbar
|
||||
// library redraws a spinner from its own goroutine unless told not to.
|
||||
const spinnerIdle = 300 * 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgressWarningsOnOwnLines drives the terminal display of the walk
|
||||
// pass: every warning must land on a line of its own between redraws of
|
||||
// the spinner, and nothing may redraw while no method is being called,
|
||||
// or a warning could be garbled by a redraw from another goroutine.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestProgressWarningsOnOwnLines(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
|
||||
p := &progress{
|
||||
label: "walk", total: -1, start: time.Now(),
|
||||
bar: newBar("walk", -1),
|
||||
}
|
||||
|
||||
for _, w := range []string{"first", "second", "third"} {
|
||||
p.increment()
|
||||
p.warnf("warning %s", w)
|
||||
}
|
||||
|
||||
p.increment()
|
||||
|
||||
before := stderr()
|
||||
|
||||
time.Sleep(spinnerIdle)
|
||||
|
||||
if after := stderr(); after != before {
|
||||
t.Errorf("stderr written while idle: %q", after[len(before):])
|
||||
}
|
||||
|
||||
// A terminal shows each line as the text after its last carriage
|
||||
// return.
|
||||
var warnings []string
|
||||
|
||||
for line := range strings.SplitSeq(before, "\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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user