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 // 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/5] load: counting the records, elapsed 10s, eta unknown", }, { p: &progress{ label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20, }, want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s", }, { p: &progress{label: scanPhase("hash"), unit: scanUnit, total: 100}, want: "[3/5] hash: [0/100] 0%, 0 files/s, elapsed 10s, eta unknown", }, { p: &progress{label: scanPhase("update"), unit: scanUnit, total: 0}, want: "[4/5] update: [0/0] 100%, 0 files/s, elapsed 10s, eta 0s", }, { p: &progress{ label: scanPhase("walk"), unit: scanUnit, 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"), unit: scanUnit, 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"), unit: scanUnit, total: -1, count: 20, }, want: "[2/5] walk: 20 files, 2 files/s, elapsed 10s, eta 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, " + "eta 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, true); 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, or what the phase waits for, // then what it says of the stall, 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}}, } content := &progress{ label: scanPhase("content"), total: -1, waitingFor: searchingDatabase, start: ago, rateStart: ago, } started := now.Add(-50 * time.Second) stalled := &progress{ label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20, doing: readingFiles, start: started, rateStart: started, lastItem: now.Add(-42 * time.Second), drawn: []drawnCount{{at: now.Add(-rateWindow), count: 20}}, } cases := []struct { p *progress room int percent bool want string fits bool }{{ p: hash, room: 57, percent: true, want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s", fits: true, }, { p: hash, room: 56, percent: true, want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40s", fits: true, }, { p: hash, room: 45, percent: true, want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40", fits: false, }, { // Before the library's bar, which shows the percent. p: hash, room: 57, percent: false, want: "[3/5] hash: [20/100], 2 files/s, elapsed 10s, eta 40s", fits: true, }, { p: content, room: 40, percent: true, want: "[5/5] content: elapsed 10s, eta unknown", fits: true, }, { p: stalled, room: 101, percent: true, want: "[3/5] hash: [20/100] 20%, 0 files/s, no files finished for " + "42s, reading files, elapsed 50s, eta 3m20s", fits: true, }, { p: stalled, room: 100, percent: true, want: "[3/5] hash: [20/100] 20%, no files finished for 42s, " + "reading files, elapsed 50s, eta 3m20s", fits: true, }, { p: stalled, room: 89, percent: true, want: "[3/5] hash: [20/100] 20%, elapsed 50s, eta 3m20s", fits: true, }} for _, tc := range cases { got, fits := tc.p.line(now, tc.room, tc.percent) if got != tc.want || fits != tc.fits { t.Errorf("line(%d, %t) = %q, %t, want %q, %t", tc.room, tc.percent, got, fits, tc.want, tc.fits) } } } // TestSetTotalStartsRate checks that setTotal starts a phase's rate, // its ETA and its time with no record read from when the total becomes // known, not from when the phase started waiting for it. 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, doing: readingDatabase, start: waited, rateStart: waited, lastItem: waited, drawn: []drawnCount{{at: waited}}, } p.setTotal(100) // Ten seconds after the total came, no record has been read yet. clock.Add(10 * time.Second) want := "[1/5] load: [0/100] 0%, 0 files/s, no files finished for 10s, " + "reading the database, elapsed 30s, eta unknown" if got, _ := p.line(clock.Now(), math.MaxInt, true); got != want { t.Errorf("line = %q, want %q", got, want) } // Then 20 are. for range 20 { p.increment() } want = "[1/5] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s" if got, _ := p.line(clock.Now(), math.MaxInt, true); 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/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: countingRecords, }, nil, testRedraw, time.Now) awaitOutput(t, stderr, "[1/5] load: counting the records, elapsed 0s, eta 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 so, and what the phase is doing. 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, doing: readingFiles, }, 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/5] hash: [20/100] 20%, 4 files/s, elapsed 5s, eta 20s\n", 3) clock.Add(time.Second) awaitOutput(t, stderr, "[3/5] hash: [20/100] 20%, 0 files/s, elapsed 6s, eta 24s\n", 3) clock.Add(4 * time.Second) awaitOutput(t, stderr, "[3/5] hash: [20/100] 20%, 0 files/s, no files finished for 10s, "+ "reading files, elapsed 10s, eta 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 ETA 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, doing: readingFiles, }, 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/5] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown", "[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 5s, eta unknown", "[3/5] hash: [0/1000] 0%, 0 files/s, no files finished for 10s, " + "reading files, elapsed 10s, eta unknown", "[3/5] hash: [100/1000] 10%, 20 files/s, elapsed 15s, eta 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 ETA 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, doing: readingFiles, }, 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/5] hash: [50/1000] 5%, 10 files/s, elapsed 15s, eta 4m45s\n" + "[3/5] hash: [50/1000] 5%, 0 files/s, elapsed 15s, eta 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, spinner or bar, 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. 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 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, terminalWidth, 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:] } // TestTerminalLinesKeepETA 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 ETA stay whole. // //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr func TestTerminalLinesKeepETA(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/5] walk: [1234567/~1200000] 103%, elapsed 2h30m0s, " + "eta unknown", }, { params: progressParams{ label: scanPhase("hash"), unit: scanUnit, total: 9876543, }, want: "[3/5] hash: [1234567/9876543] 12%, elapsed 2h30m0s, " + "eta 17h30m0s", }, { params: progressParams{ label: scanPhase("content"), unit: scanUnit, total: 9876543, }, want: "[5/5] content: [1234567/9876543] 12%, elapsed 2h30m0s, " + "eta 17h30m0s", }, { params: progressParams{ label: treesPhase("read"), unit: "records", total: 9876543, }, want: "[2/4] read: [1234567/9876543] 12%, elapsed 2h30m0s, " + "eta 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) } } } // 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("content"), 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) } } } } } // 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 these widths the // spinner stays a spinner and the bar stays a bar. // //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, }, 90, }, } 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, 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, 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, // spinner and bar, 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. // //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, terminalWidth, 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) } }