package main import ( "fmt" "io" "math" "os" "path/filepath" "slices" "strings" "sync" "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 // hashStalledOn is where the hash phase says it waits once it has // stalled, as hashPhase gives it. const hashStalledOn = "disk/db" // testClock is a clock a test moves by hand, for a display that reads // it from its own goroutine too. type testClock struct { mu sync.Mutex now time.Time } // Now returns the clock's time. func (c *testClock) Now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.now } // Add moves the clock on by d. func (c *testClock) Add(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.now = c.now.Add(d) } //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, with no draw since the first and the last // item just completed. func TestProgressLine(t *testing.T) { t.Parallel() now := time.Now() ago := now.Add(-10 * time.Second) cases := []struct { p *progress want string }{{ p: &progress{ label: scanPhase("load"), total: -1, waitingFor: countingRecords, }, want: "[1/4] load: counting the records, elapsed 10s, remain unknown", }, { p: &progress{ label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20, }, want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, remain 40s", }, { p: &progress{label: scanPhase("hash"), unit: scanUnit, total: 100}, want: "[3/4] hash: [0/100] 0%, 0 files/s, elapsed 10s, remain unknown", }, { p: &progress{label: scanPhase("update"), unit: scanUnit, total: 0}, want: "[4/4] update: [0/0] 100%, 0 files/s, elapsed 10s, remain 0s", }, { p: &progress{ label: scanPhase("walk"), unit: scanUnit, total: 100, estimate: true, count: 20, }, want: "[2/4] walk: [20/~100] 20%, 2 files/s, elapsed 10s, remain 40s", }, { // Past the estimate the walk cannot tell how long it has left. p: &progress{ label: scanPhase("walk"), unit: scanUnit, total: 100, estimate: true, count: 120, }, want: "[2/4] walk: [120/~100] 120%, 12 files/s, elapsed 10s, " + "remain unknown", }, { // A first scan's walk has no estimate. p: &progress{ label: scanPhase("walk"), unit: scanUnit, total: -1, count: 20, }, want: "[2/4] walk: 20 files, 2 files/s, elapsed 10s, remain unknown", }, { // The rate is in what the phase counts. p: &progress{ label: treesPhase("group"), unit: "directories", total: 100, count: 20, }, want: "[3/4] group: [20/100] 20%, 2 directories/s, elapsed 10s, " + "remain 40s", }} for _, tc := range cases { tc.p.start, tc.p.rateStart, tc.p.lastItem = ago, ago, now tc.p.drawn = []drawnCount{{at: ago}} if got := tc.p.line(now, math.MaxInt, false); got != tc.want { t.Errorf("line = %q, want %q", got, tc.want) } } } // TestLineFitsRoom checks how a line is fitted to the room it has, ten // seconds into the phase, and fifty into a phase with no file read for // the last 42: it leaves out the rate, what the phase waits for, or the // words of a stalled phase, and is cut only when what is left is still // too wide. func TestLineFitsRoom(t *testing.T) { t.Parallel() now := time.Now() ago := now.Add(-10 * time.Second) hash := &progress{ label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20, start: ago, rateStart: ago, lastItem: now, drawn: []drawnCount{{at: ago}}, } load := &progress{ label: scanPhase("load"), total: -1, waitingFor: countingRecords, start: ago, rateStart: ago, } started := now.Add(-50 * time.Second) stalled := &progress{ label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20, stalledOn: hashStalledOn, start: started, rateStart: started, lastItem: now.Add(-42 * time.Second), drawn: []drawnCount{{at: now.Add(-rateWindow), count: 20}}, } cases := []struct { p *progress room int bar bool want string }{{ p: hash, room: 60, want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, remain 40s", }, { p: hash, room: 59, want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40s", }, { p: hash, room: 48, want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40", }, { // The bar shows the percent. p: hash, room: 74, bar: true, want: "[3/4] hash: [20/100], 2 files/s, elapsed 10s, remain 40s" + " 20% |██ |", }, { p: load, room: 43, want: "[1/4] load: elapsed 10s, remain unknown", }, { p: stalled, room: 68, want: "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " + "remain 3m20s", }, { p: stalled, room: 67, want: "[3/4] hash: [20/100] 20%, elapsed 50s, remain 3m20s", }} for _, tc := range cases { if got := tc.p.line(now, tc.room, tc.bar); got != tc.want { t.Errorf("line(%d, %t) = %q, want %q", tc.room, tc.bar, got, tc.want) } } } // TestSetTotalStartsRate checks that setTotal starts a phase's rate, // its time remaining and its time with no record read from when the // total becomes known, not from when the phase started waiting for it: // after 20 seconds of waiting for the total, the phase has not stalled 5 // seconds after it, and has 10 seconds after it with no record read. func TestSetTotalStartsRate(t *testing.T) { t.Parallel() clock := &testClock{now: time.Now()} waited := clock.Now().Add(-20 * time.Second) p := &progress{ now: clock.Now, label: scanPhase("load"), unit: scanUnit, total: -1, stalledOn: "db", start: waited, rateStart: waited, lastItem: waited, drawn: []drawnCount{{at: waited}}, } p.setTotal(100) clock.Add(5 * time.Second) want := "[1/4] load: [0/100] 0%, 0 files/s, elapsed 25s, remain unknown" if got := p.line(clock.Now(), math.MaxInt, false); got != want { t.Errorf("line = %q, want %q", got, want) } clock.Add(5 * time.Second) want = "[1/4] load: [0/100] 0%, stalled db, elapsed 30s, remain unknown" if got := p.line(clock.Now(), math.MaxInt, false); got != want { t.Errorf("line = %q, want %q", got, want) } // Then 20 records are read. for range 20 { p.increment() } want = "[1/4] load: [20/100] 20%, 2 files/s, elapsed 30s, remain 40s" if got := p.line(clock.Now(), math.MaxInt, false); 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"), unit: scanUnit, total: 1000, }) for range 1000 { p.increment() } p.finish() lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n") want := "[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 0s, remain unknown" if len(lines) != 2 || lines[0] != want || !strings.HasPrefix(lines[1], "[3/4] 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: countingRecords, }, nil, testRedraw, time.Now) awaitOutput(t, stderr, "[1/4] load: counting the records, elapsed 0s, remain unknown\n", 3) p.finish() } // TestPlainProgressStalledProducer checks that a phase whose files stop // coming, as when one large file is being read or a disk is slow, goes // on printing its line every interval: the rate falls to 0 once no file // has been read for rateWindow, and once none has for stallTime the // line says the phase has stalled, and where it waits, in place of the // rate. The test moves the display's clock by hand. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestPlainProgressStalledProducer(t *testing.T) { stderr := captureStderr(t) clock := &testClock{now: time.Now()} p := startProgress(progressParams{ label: scanPhase("hash"), unit: scanUnit, total: 100, stalledOn: hashStalledOn, }, nil, testRedraw, clock.Now) // Pass or fail, the display ends before stderr is put back. t.Cleanup(p.finish) // 20 files read as the phase starts, then none. for range 20 { p.increment() } clock.Add(5 * time.Second) awaitOutput(t, stderr, "[3/4] hash: [20/100] 20%, 4 files/s, elapsed 5s, remain 20s\n", 3) clock.Add(time.Second) awaitOutput(t, stderr, "[3/4] hash: [20/100] 20%, 0 files/s, elapsed 6s, remain 24s\n", 3) clock.Add(4 * time.Second) awaitOutput(t, stderr, "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 10s, remain 40s\n", 3) } // TestPlainProgressRateAfterStall draws a phase's plain line by hand, a // little over plainInterval after the line before, as a late tick // does, through a stall and a burst of files after it: the rate covers // the files read since the line before, while the time remaining comes // from the phase's average. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestPlainProgressRateAfterStall(t *testing.T) { stderr := captureStderr(t) clock := &testClock{now: time.Now()} // No timed draws: the test draws. p := startProgress(progressParams{ label: scanPhase("hash"), unit: scanUnit, total: 1000, stalledOn: hashStalledOn, }, nil, time.Hour, clock.Now) late := plainInterval + time.Millisecond clock.Add(late) p.draw() clock.Add(late) p.draw() for range 100 { p.increment() } clock.Add(late) p.finish() want := []string{ "[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 0s, remain unknown", "[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 5s, remain unknown", "[3/4] hash: [0/1000] 0%, stalled disk/db, elapsed 10s, remain unknown", "[3/4] hash: [100/1000] 10%, 20 files/s, elapsed 15s, remain 2m15s", } lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n") if !slices.Equal(lines, want) { t.Errorf("stderr lines = %q, want %q", lines, want) } } // TestProgressRateAfterStallRedrawn draws a phase by hand every // redrawInterval, as a terminal does, through a stall of stallTime and // a burst of files after it: the rate covers the files read over the // last rateWindow, and falls to 0 once the burst is older than that, // while the time remaining comes from the phase's average. Plain lines // show each draw. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestProgressRateAfterStallRedrawn(t *testing.T) { stderr := captureStderr(t) clock := &testClock{now: time.Now()} // No timed draws: the test draws. p := startProgress(progressParams{ label: scanPhase("hash"), unit: scanUnit, total: 1000, stalledOn: hashStalledOn, }, nil, time.Hour, clock.Now) // redraw draws the display every redrawInterval for d. redraw := func(d time.Duration) { for range d / redrawInterval { clock.Add(redrawInterval) p.draw() } } redraw(stallTime) for range 50 { p.increment() } redraw(rateWindow + redrawInterval) p.finish() want := "[3/4] hash: [50/1000] 5%, 10 files/s, elapsed 15s, remain 4m45s\n" + "[3/4] hash: [50/1000] 5%, 0 files/s, elapsed 15s, remain 4m47s\n" if !strings.Contains(stderr(), want) { t.Errorf("stderr = %q, want the lines %q", stderr(), 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 } // TestTerminalRedrawsWhileWaiting checks that the terminal display of a // phase, with a bar or without, keeps being drawn while the phase waits // for its next item, with the elapsed time as of now, and shows every // item completed before the wait. The terminal is 120 columns wide, so // the hash phase's display has room for its bar. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalRedrawsWhileWaiting(t *testing.T) { stderr := captureStderr(t) cases := []struct { params progressParams want string // how the line shows a count of 50 }{ { progressParams{label: scanPhase("walk"), unit: scanUnit, total: -1}, "50 files,", }, { progressParams{label: scanPhase("hash"), unit: scanUnit, total: 100}, "[50/100],", }, } for _, tc := range cases { before := len(stderr()) since := func() string { return stderr()[before:] } p := startProgress(tc.params, func() int { return 120 }, testRedraw, time.Now) 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() awaitOutput(t, since, "elapsed 1h0m0s", 1) p.finish() if shown := lastFrame(since()); !strings.Contains(shown, tc.want) { t.Errorf("terminal shows %q, want %q", shown, tc.want) } } } // drawLate draws the display params describes on a terminal width // columns wide as it starts and once more as it ends, with count items // done two and a half hours into the phase, the last just now, and // returns what it wrote to stderr. func drawLate(stderr func() string, params progressParams, width int, count int64, ) string { before := len(stderr()) clock := &testClock{now: time.Now()} // No timed draws come between the two. p := startProgress(params, func() int { return width }, time.Hour, clock.Now) // Without counting the items one by one or waiting the hours. clock.Add(150 * time.Minute) p.mu.Lock() p.count, p.lastItem = count, clock.Now() p.mu.Unlock() p.finish() return stderr()[before:] } // TestTerminalLinesKeepTimeRemaining checks the line each display shows // on an 80-column terminal at a large scan's counts and times, and the // line of trees' read phase at the same counts: the rate is left out, // so that the elapsed time and the time remaining stay whole. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalLinesKeepTimeRemaining(t *testing.T) { stderr := captureStderr(t) cases := []struct { params progressParams want string }{{ // Past its estimate, the walk cannot tell how long it has left. params: progressParams{ label: scanPhase("walk"), unit: scanUnit, total: 1200000, estimate: true, }, want: "[2/4] walk: [1234567/~1200000] 103%, elapsed 2h30m0s, " + "remain unknown", }, { params: progressParams{ label: scanPhase("hash"), unit: scanUnit, total: 9876543, }, want: "[3/4] hash: [1234567/9876543] 12%, elapsed 2h30m0s, " + "remain 17h30m0s", }, { params: progressParams{ label: treesPhase("read"), unit: "records", total: 9876543, }, want: "[2/4] read: [1234567/9876543] 12%, elapsed 2h30m0s, " + "remain 17h30m0s", }} for _, tc := range cases { out := drawLate(stderr, tc.params, 80, 1234567) if shown := lastFrame(out); !strings.Contains(shown, tc.want) { t.Errorf("terminal shows %q, want %q", shown, tc.want) } } } // TestTerminalStalledLines draws every phase's line once the phase has // stalled, its last item done 42 seconds before. At an ordinary scan's // counts and times, 12345 of 45678 items done four and a half minutes // in, on a terminal 100 columns wide, the line shows where the phase // waits whole, beside the phase, progress, elapsed time and time // remaining. At a large scan's, 1234567 of 9876543 two and a half hours // in, on an 80-column terminal, those four still show whole, as the // words give way first where they do not fit. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalStalledLines(t *testing.T) { stderr := captureStderr(t) // drawStalled draws the display params describes on a terminal width // columns wide as it starts and once more as it ends, with count of // total items done elapsed into the phase, and returns what the // terminal shows. drawStalled := func(params progressParams, width int, total, count int64, elapsed time.Duration, ) string { before := len(stderr()) clock := &testClock{now: time.Now()} params.total = total // No timed draws come between the two. p := startProgress(params, func() int { return width }, time.Hour, clock.Now) clock.Add(elapsed - 42*time.Second) p.mu.Lock() p.count, p.lastItem = count, clock.Now() p.mu.Unlock() clock.Add(42 * time.Second) p.finish() return lastFrame(stderr()[before:]) } phases := []progressParams{ {label: scanPhase("load"), stalledOn: "db"}, {label: scanPhase("walk"), estimate: true, stalledOn: "disk"}, {label: scanPhase("hash"), stalledOn: hashStalledOn}, {label: scanPhase("update"), stalledOn: "db"}, {label: treesPhase("read"), stalledOn: "db"}, {label: treesPhase("group"), stalledOn: "cpu"}, {label: treesPhase("write"), stalledOn: "out"}, {label: reportPhase("read"), stalledOn: "db/out"}, } for _, params := range phases { approx := "" if params.estimate { approx = "~" } // The percent is in the line, or beside the bar. shown := drawStalled(params, 100, 45678, 12345, 4*time.Minute+30*time.Second) for _, want := range []string{ params.label + ": [12345/" + approx + "45678]", ", stalled " + params.stalledOn + ", elapsed 4m30s, remain 12m9s", } { if !strings.Contains(shown, want) { t.Errorf("terminal shows %q, want %q", shown, want) } } shown = drawStalled(params, 80, 9876543, 1234567, 150*time.Minute) for _, want := range []string{ params.label + ": [1234567/" + approx + "9876543] 12%, ", ", elapsed 2h30m0s, remain 17h30m0s", } { if !strings.Contains(shown, want) { t.Errorf("terminal shows %q, want %q", shown, want) } } } } // TestTerminalLinesFitWidth draws a large scan's bar display and a small // scan's, two and a half hours into the phase, on terminals from 120 // columns down to 40, and checks that every line stays clear of the // terminal's last column. A line that reaches it wraps, and every // redraw then leaves a line behind. The bar is drawn where it has room, // as at 120 columns, and left out where it has none. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalLinesFitWidth(t *testing.T) { stderr := captureStderr(t) for _, width := range []int{120, 100, 80, 72, 60, 50, 40} { for _, total := range []int64{9876543, 20000} { params := progressParams{ label: scanPhase("update"), unit: scanUnit, total: total, } out := drawLate(stderr, params, width, total/8) if width == 120 && !strings.Contains(out, "% |") { t.Errorf("no bar on a terminal %d wide: %q", width, out) } lines := strings.FieldsFuncSeq(out, func(r rune) bool { return r == '\r' || r == '\n' }) for line := range lines { if n := utf8.RuneCountInString(line); n >= width { t.Errorf("a line of %d columns on a terminal %d wide: %q", n, width, line) } } } } } // TestTerminalLinesStartWithSpinner draws a display with no total and // one with a bar, two and a half hours into the phase, on a terminal 120 // columns wide. Each line must start with the library's spinner in the // first column, then a space and the phase's label. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalLinesStartWithSpinner(t *testing.T) { stderr := captureStderr(t) for _, params := range []progressParams{ {label: scanPhase("walk"), unit: scanUnit, total: -1}, {label: scanPhase("hash"), unit: scanUnit, total: 9876543}, } { shown := lastFrame(drawLate(stderr, params, 120, 1234567)) spinner, rest, _ := strings.Cut(shown, " ") if utf8.RuneCountInString(spinner) != 1 || !strings.HasPrefix(rest, params.label+": ") { t.Errorf("terminal shows %q, want the spinner, a space and %q", shown, params.label) } if params.total > 0 && !strings.Contains(shown, "% |") { t.Errorf("terminal shows %q, want a bar", shown) } } } // TestTerminalBarStaysAtRightEdge draws the hash phase's display two // and a half hours in, at two counts whose lines differ in length, on // terminals 110 and 120 columns wide. The bar is partly filled at the // first count and full at the second. It must start the same number of // columns from the terminal's right edge each time, and end the same // number of columns from it: a line that grows or shrinks, as its time // remaining does, never moves the bar, and nor do the bar's filled // cells. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalBarStaysAtRightEdge(t *testing.T) { stderr := captureStderr(t) params := progressParams{ label: scanPhase("hash"), unit: scanUnit, total: 9876543, } widths := []int{110, 120} counts := []int64{1234567, 9876543} startFromEdge := make([]int, 0, len(widths)*len(counts)) endFromEdge := make([]int, 0, len(widths)*len(counts)) for _, width := range widths { for _, count := range counts { shown := lastFrame(drawLate(stderr, params, width, count)) beforeBar, _, found := strings.Cut(shown, "% |") if !found { t.Fatalf("terminal %d wide shows %q, want a bar", width, shown) } // The bar ends with the last "|". throughBar := shown[:strings.LastIndex(shown, "|")+1] startFromEdge = append(startFromEdge, width-utf8.RuneCountInString(beforeBar)) endFromEdge = append(endFromEdge, width-utf8.RuneCountInString(throughBar)) } } if slices.Min(startFromEdge) != slices.Max(startFromEdge) { t.Errorf("bar starts %v columns from the right edge, want the same "+ "every time", startFromEdge) } if slices.Min(endFromEdge) != slices.Max(endFromEdge) { t.Errorf("bar ends %v columns from the right edge, want the same "+ "every time", endFromEdge) } } // TestTerminalRateGivesWayBeforeBar draws the hash phase's display two // and a half hours in. On a terminal 90 columns wide the line fits // beside the bar without its rate, but not with it: the bar is drawn // and the rate is not. On a terminal 85 columns wide the line does not // fit beside the bar even without its rate: neither is drawn, though // the rate would fit where the bar was. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalRateGivesWayBeforeBar(t *testing.T) { stderr := captureStderr(t) params := progressParams{ label: scanPhase("hash"), unit: scanUnit, total: 9876543, } shown := lastFrame(drawLate(stderr, params, 90, 1234567)) if !strings.Contains(shown, "% |") || strings.Contains(shown, "files/s") { t.Errorf("terminal 90 wide shows %q, want the bar and no rate", shown) } shown = lastFrame(drawLate(stderr, params, 85, 1234567)) if strings.Contains(shown, "% |") || strings.Contains(shown, "files/s") { t.Errorf("terminal 85 wide shows %q, want neither bar nor rate", shown) } } // TestTerminalSpinnerTurnsBesideBar draws the hash phase's display, its // bar partly filled, several times on a terminal 120 columns wide. The // spinner must turn at every draw. A filled cell of the bar takes one // column but three bytes. A line measured in bytes would seem too wide // for the terminal, and every draw would start the display over, its // spinner included. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalSpinnerTurnsBesideBar(t *testing.T) { stderr := captureStderr(t) params := progressParams{ label: scanPhase("hash"), unit: scanUnit, total: 9876543, } // No timed draws: the test draws. p := startProgress(params, func() int { return 120 }, time.Hour, time.Now) p.mu.Lock() p.count = 1234567 p.mu.Unlock() const draws = 4 spinners := make([]string, 0, draws) for range draws { before := len(stderr()) p.draw() spinner, _, _ := strings.Cut(lastFrame(stderr()[before:]), " ") spinners = append(spinners, spinner) } p.finish() for i := 1; i < len(spinners); i++ { if spinners[i] == spinners[i-1] { t.Errorf("spinners at successive draws are %q, want each to "+ "differ from the one before", spinners) break } } } // TestTerminalNarrowedRedrawsInPlace draws a large scan's display, two // and a half hours into the phase, on a terminal 120 columns wide, then // makes the terminal narrower than that line and draws it again a few // times. Nothing written after that, the erase before each redraw // included, may reach the narrower terminal's last column: it would // wrap, and every redraw would leave a line behind. At the narrower // width the hash phase's bar gives way. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalNarrowedRedrawsInPlace(t *testing.T) { stderr := captureStderr(t) cases := []struct { params progressParams narrow int }{ { progressParams{label: scanPhase("walk"), unit: scanUnit, total: -1}, 60, }, { progressParams{ label: scanPhase("hash"), unit: scanUnit, total: 9876543, }, 80, }, } for _, tc := range cases { width := 120 clock := &testClock{now: time.Now()} // No timed draws: the test draws. p := startProgress(tc.params, func() int { return width }, time.Hour, clock.Now) clock.Add(150 * time.Minute) p.mu.Lock() p.count, p.lastItem = 1234567, clock.Now() p.mu.Unlock() p.draw() width = tc.narrow before := len(stderr()) p.draw() p.draw() p.finish() lines := strings.FieldsFuncSeq(stderr()[before:], func(r rune) bool { return r == '\r' || r == '\n' }) for line := range lines { if n := utf8.RuneCountInString(line); n >= width { t.Errorf("%d columns written on a terminal made %d wide: %q", n, width, line) } } } } // TestProgressWarningsOnOwnLines drives the terminal displays, with a // bar and without, 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, terminalWidth, testRedraw, time.Now) // 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) } } // TestProgressWriterRowsOnOwnLines drives the terminal displays, with a // bar and without, through a run of rows written through the display's // writer to the same terminal, as stdout is in a plain "sfdupes trees", // each written out at once, for many redraws: the terminal must show // every row whole, in order, on a line of its own. The terminal is 120 // columns wide, so the write phase's display has room for its bar. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestProgressWriterRowsOnOwnLines(t *testing.T) { stderr := captureStderr(t) cases := []progressParams{ { label: treesPhase("open"), total: -1, waitingFor: "opening the database", }, {label: treesPhase("write"), unit: "groups", total: 10}, } var want []string for _, params := range cases { p := startProgress(params, func() int { return 120 }, testRedraw, time.Now) out := p.writer(os.Stderr) // No pause between rows: one is garbled only if a redraw lands // on its line. for start := time.Now(); time.Since(start) < 50*testRedraw; { row := fmt.Sprintf("row %d", len(want)) want = append(want, row) _, err := fmt.Fprintln(out, row) if err == nil { err = out.Flush() } if err != nil { t.Fatal(err) } } p.finish() } // A terminal shows each line as the text after its last carriage // return. var shown []string for line := range strings.SplitSeq(stderr(), "\n") { if strings.Contains(line, "row ") { shown = append(shown, line[strings.LastIndex(line, "\r")+1:]) } } if len(shown) != len(want) { t.Fatalf("terminal shows %d rows, want %d", len(shown), len(want)) } for i := range want { if shown[i] != want[i] { t.Fatalf("terminal shows %q, want %q", shown[i], want[i]) } } } // TestProgressWriterStalledStdoutKeepsLines writes a row larger than a // pipe holds through the display's writer to a pipe nobody reads yet, // as stdout is when its reader has stopped while stderr goes to a file: // while the write waits, the display must go on printing its line. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestProgressWriterStalledStdoutKeepsLines(t *testing.T) { stderr := captureStderr(t) r, w, err := os.Pipe() if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = w.Close() _ = r.Close() }) p := startProgress(progressParams{label: treesPhase("write"), total: 1}, nil, testRedraw, time.Now) out := p.writer(w) written := make(chan error, 1) go func() { _, writeErr := fmt.Fprintln(out, strings.Repeat("x", ioBufSize)) written <- writeErr }() // Pass or fail, the pipe is then read so that the write ends, and // the display ends before stderr is put back. t.Cleanup(func() { go func() { _, _ = io.Copy(io.Discard, r) }() writeErr := <-written if writeErr != nil { t.Error(writeErr) } p.finish() }) // The first byte read shows that the write has started; the rest // of the row waits until the pipe is read. _, err = r.Read(make([]byte, 1)) if err != nil { t.Fatal(err) } line := "[4/4] write: " awaitOutput(t, stderr, line, strings.Count(stderr(), line)+3) } // 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}, terminalWidth, redrawInterval, time.Now) 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}, terminalWidth, testRedraw, time.Now) 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) } }