package main import ( "os" "path/filepath" "strings" "testing" "time" ) // testRedraw is the redraw interval the tests give a display, so that // many redraws fit in a few milliseconds. const testRedraw = time.Millisecond // testDeadline bounds how long a test waits for a display to draw what // it expects. const testDeadline = 5 * time.Second //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) } } } func TestPhaseLabel(t *testing.T) { t.Parallel() phases := []string{"load", "walk", "hash"} if got := phaseLabel(phases, "walk"); got != "[2/3] walk" { t.Errorf("phaseLabel = %q, want %q", got, "[2/3] walk") } } // TestProgressLine checks the line a display shows in each state, ten // seconds into the phase. func TestProgressLine(t *testing.T) { t.Parallel() ago := time.Now().Add(-10 * time.Second) cases := []struct { p *progress want string }{{ p: &progress{ label: scanPhase("load"), total: -1, waitingFor: "counting the records", }, want: "[1/5] load: counting the records, elapsed 10s, eta unknown", }, { p: &progress{label: scanPhase("hash"), total: 100, count: 20}, want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s", }, { p: &progress{label: scanPhase("hash"), total: 100}, want: "[3/5] hash: [0/100] 0%, 0 files/s, elapsed 10s, eta unknown", }, { p: &progress{label: scanPhase("update"), total: 0}, want: "[4/5] update: [0/0] 100%, 0 files/s, elapsed 10s, eta 0s", }, { p: &progress{ label: scanPhase("walk"), total: 100, estimate: true, count: 20, }, want: "[2/5] walk: [20/~100] 20%, 2 files/s, elapsed 10s, eta 40s", }, { // Past the estimate the walk cannot tell how long it has left. p: &progress{ label: scanPhase("walk"), total: 100, estimate: true, count: 120, }, want: "[2/5] walk: [120/~100] 120%, 12 files/s, elapsed 10s, " + "eta unknown", }, { // A first scan's walk has no estimate. p: &progress{label: scanPhase("walk"), total: -1, count: 20}, want: "[2/5] walk: 20 files, 2 files/s, elapsed 10s, eta unknown", }} for _, tc := range cases { tc.p.start, tc.p.rateStart = ago, ago if got := tc.p.line(); got != tc.want { t.Errorf("line = %q, want %q", got, tc.want) } } } // TestSetTotalStartsRate checks that a phase's rate, and so its ETA, // count from when its total became known, not from when it started // waiting for it. func TestSetTotalStartsRate(t *testing.T) { t.Parallel() p := progress{ label: scanPhase("load"), total: 100, count: 20, start: time.Now().Add(-20 * time.Second), rateStart: time.Now().Add(-10 * time.Second), } want := "[1/5] load: [20/100] 20%, 2 files/s, elapsed 20s, eta 40s" if got := p.line(); got != want { t.Errorf("line = %q, want %q", got, want) } } // TestPlainProgressPrintsAtStartAndEnd checks that when stderr is not a // terminal a phase prints its line the moment it starts, before its // first item, nothing for the items it completes before plainInterval // has passed, and its line again as it ends. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestPlainProgressPrintsAtStartAndEnd(t *testing.T) { stderr := captureStderr(t) p := newProgress(progressParams{label: scanPhase("hash"), total: 1000}) for range 1000 { p.increment() } p.finish() lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n") want := "[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown" if len(lines) != 2 || lines[0] != want || !strings.HasPrefix(lines[1], "[3/5] hash: [1000/1000] 100%, ") { t.Errorf("stderr lines = %q, want %q, then the line at 1000", lines, want) } } // TestPlainProgressPrintsWhileWaiting checks that a phase waiting with // no completed items still prints a line every interval. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestPlainProgressPrintsWhileWaiting(t *testing.T) { stderr := captureStderr(t) p := startProgress(progressParams{ label: scanPhase("load"), total: -1, waitingFor: "counting the records", }, nil, testRedraw) want := "[1/5] load: counting the records, elapsed 0s, eta unknown\n" for deadline := time.Now().Add(testDeadline); strings.Count(stderr(), want) < 3 && time.Now().Before(deadline); { time.Sleep(testRedraw) } p.finish() if got := stderr(); strings.Count(got, want) < 3 { t.Errorf("stderr = %q, want %q at least three times", got, want) } } // lastFrame returns what a terminal shows of the frames a display drew: // the last one. The library starts each frame with a carriage return // and erases the previous one with spaces first. func lastFrame(out string) string { var shown string for frame := range strings.SplitSeq(out, "\r") { if strings.TrimSpace(frame) != "" { shown = frame } } return shown } // awaitFrame waits until the last frame a terminal display drew to // stderr shows want, and fails the test if it never does. func awaitFrame(t *testing.T, stderr func() string, want string) { t.Helper() for deadline := time.Now().Add(testDeadline); !strings.Contains( lastFrame(stderr()), want); time.Sleep(testRedraw) { if time.Now().After(deadline) { t.Fatalf("terminal shows %q, want %q", lastFrame(stderr()), want) } } } // TestTerminalRedrawsWhileWaiting checks that the terminal display of a // phase, with a total or without, keeps showing the elapsed time while // the phase waits for its next item, and shows every item completed // before the wait. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalRedrawsWhileWaiting(t *testing.T) { stderr := captureStderr(t) counts := map[int64]string{-1: "50 files", 100: "[50/100]"} for total, count := range counts { p := startProgress(progressParams{ label: scanPhase("hash"), total: total, }, newSpinner(), testRedraw) for range 50 { p.increment() } // An hour into the phase, without waiting an hour. p.mu.Lock() p.start = p.start.Add(-time.Hour) p.mu.Unlock() awaitFrame(t, stderr, "elapsed 1h0m0s") if shown := lastFrame(stderr()); !strings.Contains(shown, count) { t.Errorf("terminal shows %q, want %q", shown, count) } p.finish() } } // TestProgressWarningsOnOwnLines drives the terminal display through a // run of warnings with no items between them, as when the walk meets // many unreadable paths, for many 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 := startProgress(progressParams{label: scanPhase("walk"), total: -1}, newSpinner(), testRedraw) // 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) < 50*testRedraw; issued++ { p.warnf("warning") } p.finish() // 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) } } // TestTerminalStoppedShortKeepsCount checks that the terminal display // of a phase that stops before its total, as an interrupted one does, // is left showing the count it stopped at. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalStoppedShortKeepsCount(t *testing.T) { stderr := captureStderr(t) p := startProgress(progressParams{label: scanPhase("hash"), total: 10}, newSpinner(), redrawInterval) p.increment() p.increment() p.finish() if shown := lastFrame(stderr()); !strings.Contains(shown, "[2/10]") { t.Errorf("terminal shows %q, want a count of 2 of 10", shown) } } // TestWarningAfterLastItemKeepsLine checks that a warning printed after // a phase's last item, as for a failed read of the last file, leaves // the phase's last line on the terminal below the warning. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestWarningAfterLastItemKeepsLine(t *testing.T) { stderr := captureStderr(t) p := startProgress(progressParams{label: scanPhase("hash"), total: 2}, newSpinner(), redrawInterval) p.increment() p.increment() p.warnf("warning") p.finish() if shown := lastFrame(stderr()); !strings.Contains(shown, "[2/2]") { t.Errorf("terminal shows %q, want a count of 2 of 2", shown) } }