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) } }