package main import ( "os" "path/filepath" "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 through a run of warnings with no items between them, as when the // walk meets many unreadable paths, for several of the spinner's // redraws: every warning must land on a line of its own, never inside a // redraw. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestProgressWarningsOnOwnLines(t *testing.T) { stderr := captureStderr(t) p := newWalkSpinner(t) // No pause between warnings: one written straight to stderr is // garbled only if a redraw lands while it is being written. issued := 0 for start := time.Now(); time.Since(start) < spinnerIdle; issued++ { p.warnf("warning") } // 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. shown := 0 for line := range strings.SplitSeq(stderr(), "\n") { if !strings.Contains(line, "warning") { continue } shown++ if text := line[strings.LastIndex(line, "\r")+1:]; text != "warning" { t.Errorf("terminal shows %q, want %q", text, "warning") } } if shown != issued { t.Errorf("%d warning lines, want %d", shown, issued) } } // 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) } }