A very simple terminal spinner
0

Configure Feed

Select the types of activity you want to include in your feed.

Add tests for the spinner and fix some potential concurrency issues (#29)

* Add some tests and firm up the concurrent logic

* Combine Stop() check into output test

* Enable codecov

authored by

Tom Fleet and committed by
GitHub
(Apr 7, 2026, 1:53 PM +0100) cc55f8e3 961968b1

+160 -22
+8
option.go
··· 18 18 s.frameStyle = style 19 19 } 20 20 } 21 + 22 + // WithForceEnabled bypasses the terminal check, causing the spinner to render 23 + // even when the writer is not a TTY. Useful for CI environments or testing. 24 + func WithForceEnabled() Option { 25 + return func(s *Spinner) { 26 + s.forceEnabled = true 27 + } 28 + }
+20 -20
spin.go
··· 37 37 stop chan struct{} // Signal for the spinner to stop 38 38 msg string // Message to display during spinning e.g. "Loading" 39 39 wg sync.WaitGroup // Manages the rendering goroutine 40 - running atomic.Bool // Whether the spinner is currently running 41 40 messageStyle hue.Style // Colour/style for the spinner message 42 41 frameStyle hue.Style // Colour/style for the spinner animation 42 + running atomic.Bool // Whether the spinner is currently running 43 + forceEnabled bool // Bypass terminal check (useful for testing and CI) 43 44 } 44 45 45 46 // New returns a new [Spinner]. 46 47 func New(w io.Writer, msg string, options ...Option) *Spinner { 47 48 spinner := &Spinner{ 48 49 w: w, 49 - stop: make(chan struct{}), 50 50 msg: msg, 51 51 messageStyle: defaultMessageStyle, 52 52 frameStyle: defaultFrameStyle, ··· 61 61 62 62 // Start starts the spinner animation. 63 63 func (s *Spinner) Start() { 64 - if s.running.Load() || !isTerminal(s.w) { 65 - // If it's already running, or if it's not hooked up to a terminal 66 - // there's nothing for us to do 64 + if !s.forceEnabled && !isTerminal(s.w) { 65 + // Not hooked up to a terminal and not force-enabled; nothing to do. 67 66 return 68 67 } 69 68 70 - s.running.Store(true) 69 + if !s.running.CompareAndSwap(false, true) { 70 + // Already running; a concurrent or duplicate Start call is a no-op. 71 + return 72 + } 73 + 74 + s.stop = make(chan struct{}) 71 75 72 76 s.wg.Go(func() { 73 - // Store the frames and the index locally so no need for synchronisation 77 + // Store the frames and the index locally so no need for synchronisation. 74 78 frames := [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} 75 79 current := 0 80 + ticker := time.NewTicker(frameRate) 81 + defer ticker.Stop() 76 82 for { 77 83 select { 78 84 case <-s.stop: 79 85 return 80 - case <-time.Tick(frameRate): 86 + case <-ticker.C: 81 87 fmt.Fprintf(s.w, "%s%s %s...", erase, s.frameStyle.Text(frames[current]), s.messageStyle.Text(s.msg)) 82 88 current = (current + 1) % len(frames) 83 89 } ··· 87 93 88 94 // Stop halts the spinner animation. 89 95 func (s *Spinner) Stop() { 90 - if !s.running.Load() { 91 - // If it's not already running, there's nothing to do 96 + if !s.running.CompareAndSwap(true, false) { 97 + // Not running; a concurrent or duplicate Stop call is a no-op. 92 98 return 93 99 } 94 100 95 - s.stop <- struct{}{} // Signal the stop 96 - s.wg.Wait() // Wait for the render goroutine to return 97 - s.running.Store(false) 101 + close(s.stop) // Signal the goroutine to stop. 102 + s.wg.Wait() // Wait for the render goroutine to return. 98 103 99 - fmt.Fprint(s.w, erase) // Erase the line 104 + fmt.Fprint(s.w, erase) // Erase the line. 100 105 } 101 106 102 107 // Do runs the given function behind a spinner, automatically starting ··· 107 112 fn() 108 113 } 109 114 110 - // isTerminal returns whether w points to an [*os.File] and whether 111 - // that is itself a tty. 115 + // isTerminal returns whether w points to an [*os.File] that is itself a tty. 112 116 func isTerminal(w io.Writer) bool { 113 117 file, ok := w.(*os.File) 114 118 if !ok { 115 - return false 116 - } 117 - 118 - if file == nil { 119 119 return false 120 120 } 121 121
+132
spin_test.go
··· 1 + package spin_test 2 + 3 + import ( 4 + "bytes" 5 + "strings" 6 + "testing" 7 + "testing/synctest" 8 + "time" 9 + 10 + "go.followtheprocess.codes/spin" 11 + ) 12 + 13 + const eraseSeq = "\r\x1b[K" 14 + 15 + func TestSpinnerOutput(t *testing.T) { 16 + tests := []struct { 17 + name string 18 + msg string 19 + }{ 20 + {name: "short message", msg: "Loading"}, 21 + {name: "longer message", msg: "Processing data"}, 22 + } 23 + for _, tt := range tests { 24 + t.Run(tt.name, func(t *testing.T) { 25 + synctest.Test(t, func(t *testing.T) { 26 + var buf bytes.Buffer 27 + s := spin.New(&buf, tt.msg, spin.WithForceEnabled()) 28 + s.Start() 29 + time.Sleep(250 * time.Millisecond) 30 + synctest.Wait() 31 + s.Stop() 32 + 33 + output := buf.String() 34 + if !strings.Contains(output, tt.msg) { 35 + t.Errorf("output %q does not contain message %q", output, tt.msg) 36 + } 37 + if !strings.HasSuffix(output, eraseSeq) { 38 + t.Errorf("output %q does not end with ANSI erase sequence", output) 39 + } 40 + }) 41 + }) 42 + } 43 + } 44 + 45 + func TestSpinnerStopWhenNotRunningIsNoOp(t *testing.T) { 46 + var buf bytes.Buffer 47 + s := spin.New(&buf, "Loading", spin.WithForceEnabled()) 48 + s.Stop() // must not panic or write anything 49 + 50 + if buf.Len() != 0 { 51 + t.Errorf("Stop() on unstarted spinner wrote output: %q", buf.String()) 52 + } 53 + } 54 + 55 + func TestSpinnerStopCalledTwiceIsNoOp(t *testing.T) { 56 + synctest.Test(t, func(t *testing.T) { 57 + var buf bytes.Buffer 58 + s := spin.New(&buf, "Loading", spin.WithForceEnabled()) 59 + s.Start() 60 + time.Sleep(250 * time.Millisecond) 61 + synctest.Wait() 62 + s.Stop() 63 + 64 + outputAfterFirst := buf.String() 65 + 66 + s.Stop() // second Stop must not write anything extra or block 67 + 68 + if buf.String() != outputAfterFirst { 69 + t.Errorf("second Stop() wrote additional output: %q", buf.String()[len(outputAfterFirst):]) 70 + } 71 + }) 72 + } 73 + 74 + func TestSpinnerStartWhenAlreadyRunningIsNoOp(t *testing.T) { 75 + synctest.Test(t, func(t *testing.T) { 76 + var buf bytes.Buffer 77 + s := spin.New(&buf, "Loading", spin.WithForceEnabled()) 78 + s.Start() 79 + s.Start() // second call must not start a second goroutine 80 + time.Sleep(250 * time.Millisecond) 81 + synctest.Wait() 82 + s.Stop() // must not deadlock 83 + 84 + // A single erase sequence must appear at the end — not multiple. 85 + if output := buf.String(); !strings.HasSuffix(output, eraseSeq) { 86 + t.Errorf("output %q does not end with a single erase sequence after double Start", output) 87 + } 88 + }) 89 + } 90 + 91 + func TestSpinnerDoExecutesFunction(t *testing.T) { 92 + var buf bytes.Buffer 93 + s := spin.New(&buf, "Loading", spin.WithForceEnabled()) 94 + 95 + ran := false 96 + s.Do(func() { ran = true }) 97 + 98 + if !ran { 99 + t.Error("Do() did not execute the provided function") 100 + } 101 + } 102 + 103 + func TestSpinnerDoStartsAndStopsAroundFunction(t *testing.T) { 104 + synctest.Test(t, func(t *testing.T) { 105 + var buf bytes.Buffer 106 + s := spin.New(&buf, "Loading", spin.WithForceEnabled()) 107 + 108 + s.Do(func() { time.Sleep(250 * time.Millisecond) }) 109 + 110 + output := buf.String() 111 + if !strings.Contains(output, "Loading") { 112 + t.Errorf("output %q does not contain message", output) 113 + } 114 + if !strings.HasSuffix(output, eraseSeq) { 115 + t.Errorf("output %q does not end with erase sequence", output) 116 + } 117 + }) 118 + } 119 + 120 + func TestSpinnerNonTerminalWriterProducesNoOutput(t *testing.T) { 121 + synctest.Test(t, func(t *testing.T) { 122 + var buf bytes.Buffer 123 + s := spin.New(&buf, "Loading") // no WithForceEnabled — Start is a no-op 124 + s.Start() 125 + time.Sleep(250 * time.Millisecond) 126 + s.Stop() 127 + 128 + if buf.Len() != 0 { 129 + t.Errorf("non-terminal writer got output: %q", buf.String()) 130 + } 131 + }) 132 + }
-2
.github/workflows/CI.yml
··· 18 18 permissions: 19 19 contents: read 20 20 uses: FollowTheProcess/ci/.github/workflows/Go.yml@v4 21 - with: 22 - codecov: false