package main import ( "os" "path/filepath" "strings" "testing" "time" "unicode/utf8" ) // 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 setTotal starts a phase's rate, // and so its ETA, from when the total becomes known, not from when the // phase started waiting for it. func TestSetTotalStartsRate(t *testing.T) { t.Parallel() waited := time.Now().Add(-20 * time.Second) p := &progress{ label: scanPhase("load"), total: -1, start: waited, rateStart: waited, } p.setTotal(100) // Ten seconds after the total came, without waiting ten seconds, // 20 records are read. p.start = p.start.Add(-10 * time.Second) p.rateStart = p.rateStart.Add(-10 * time.Second) p.count = 20 want := "[1/5] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s" if got := p.line(); got != want { t.Errorf("line = %q, want %q", got, want) } } // awaitOutput waits until stderr holds want at least times times, and // fails the test if it never does. func awaitOutput(t *testing.T, stderr func() string, want string, times int, ) { t.Helper() deadline := time.Now().Add(testDeadline) for strings.Count(stderr(), want) < times { if time.Now().After(deadline) { t.Fatalf("stderr = %q, want %q at least %d times", stderr(), want, times) } time.Sleep(testRedraw) } } // 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", }, false, testRedraw) awaitOutput(t, stderr, "[1/5] load: counting the records, elapsed 0s, eta unknown\n", 3) p.finish() } // 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 } // TestTerminalRedrawsWhileWaiting checks that the terminal display of a // phase keeps being drawn while the phase waits for its next item, and // shows every item completed before the wait: a spinner's line with // the elapsed time as of now, and the library's bar, which works out // its elapsed time at each draw. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalRedrawsWhileWaiting(t *testing.T) { stderr := captureStderr(t) walk := startProgress(progressParams{label: scanPhase("walk"), total: -1}, true, testRedraw) for range 50 { walk.increment() } // An hour into the phase, without waiting an hour. walk.mu.Lock() walk.start = walk.start.Add(-time.Hour) walk.mu.Unlock() awaitOutput(t, stderr, "elapsed 1h0m0s", 1) walk.finish() if shown := lastFrame(stderr()); !strings.Contains(shown, "50 files,") { t.Errorf("terminal shows %q, want a count of 50", shown) } hash := startProgress(progressParams{label: scanPhase("hash"), total: 100}, true, testRedraw) for range 50 { hash.increment() } // Drawn with the 50 items, then twice more with none. awaitOutput(t, stderr, "(50/100,", 3) hash.finish() } // TestTerminalLinesFitWidth checks that every line a terminal display // draws, bar or spinner, is narrower than the terminal at the size of a // large scan. A line as wide as the terminal or wider would wrap, and // every redraw would leave a line behind. stderr is not a terminal // here, so the displays take it to be fallbackWidth wide. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalLinesFitWidth(t *testing.T) { stderr := captureStderr(t) cases := []progressParams{ {label: scanPhase("content"), total: 9876543}, {label: scanPhase("walk"), total: 9876543, estimate: true}, } for _, params := range cases { p := startProgress(params, true, testRedraw) // 1234567 items two and a half hours into the phase, without // counting them one by one or waiting the hours. p.mu.Lock() p.count = 1234567 p.start = p.start.Add(-150 * time.Minute) p.rateStart = p.start p.mu.Unlock() awaitOutput(t, stderr, "1234567", 1) p.finish() } lines := strings.FieldsFuncSeq(stderr(), func(r rune) bool { return r == '\r' || r == '\n' }) for line := range lines { if n := utf8.RuneCountInString(line); n >= fallbackWidth { t.Errorf("a line of %d columns on a terminal %d wide: %q", n, fallbackWidth, line) } } } // TestProgressWarningsOnOwnLines drives the terminal displays, spinner // and bar, 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) cases := []progressParams{ {label: scanPhase("walk"), total: -1}, {label: scanPhase("hash"), total: 10}, } issued := 0 for _, params := range cases { p := startProgress(params, true, testRedraw) // No pause between warnings: one written straight to stderr is // garbled only if a redraw lands while it is being written. 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 rather than a full bar. // //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}, true, 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) } } // TestWarningAfterLastItemKeepsBar checks that a warning printed after // a phase's last item, as for a failed read of the last file, leaves the // phase's full bar on the terminal below the warning. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestWarningAfterLastItemKeepsBar(t *testing.T) { stderr := captureStderr(t) p := startProgress(progressParams{label: scanPhase("hash"), total: 2}, true, testRedraw) p.increment() p.increment() awaitOutput(t, stderr, "(2/2,", 1) 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) } }