11import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
2- import { LoadingSpinner , withSpinner } from '../lib/spinner.js'
2+ import yoctoSpinnerFactory from 'yocto-spinner'
3+ import {
4+ LoadingSpinner ,
5+ resetEarlySpinner ,
6+ startEarlySpinner ,
7+ stopEarlySpinner ,
8+ withSpinner ,
9+ } from '../lib/spinner.js'
310
411// Mock yocto-spinner
512const mockSpinnerInstance = {
613 start : vi . fn ( ) . mockReturnThis ( ) ,
714 success : vi . fn ( ) ,
815 error : vi . fn ( ) ,
916 stop : vi . fn ( ) ,
17+ text : '' ,
1018}
1119
1220vi . mock ( 'yocto-spinner' , ( ) => ( {
@@ -29,6 +37,7 @@ vi.mock('chalk', () => ({
2937describe ( 'withSpinner' , ( ) => {
3038 beforeEach ( ( ) => {
3139 vi . clearAllMocks ( )
40+ resetEarlySpinner ( )
3241 // Reset environment variables
3342 delete process . env . TD_SPINNER
3443 delete process . env . CI
@@ -43,6 +52,7 @@ describe('withSpinner', () => {
4352
4453 afterEach ( ( ) => {
4554 vi . clearAllMocks ( )
55+ resetEarlySpinner ( )
4656 } )
4757
4858 it ( 'should handle successful operations' , async ( ) => {
@@ -140,6 +150,7 @@ describe('withSpinner', () => {
140150describe ( 'LoadingSpinner' , ( ) => {
141151 beforeEach ( ( ) => {
142152 vi . clearAllMocks ( )
153+ resetEarlySpinner ( )
143154 // Reset environment variables
144155 delete process . env . TD_SPINNER
145156 delete process . env . CI
@@ -154,6 +165,7 @@ describe('LoadingSpinner', () => {
154165
155166 afterEach ( ( ) => {
156167 vi . clearAllMocks ( )
168+ resetEarlySpinner ( )
157169 } )
158170
159171 it ( 'should start and stop spinner' , ( ) => {
@@ -197,3 +209,150 @@ describe('LoadingSpinner', () => {
197209 expect ( mockSpinnerInstance . error ) . not . toHaveBeenCalled ( )
198210 } )
199211} )
212+
213+ describe ( 'early spinner' , ( ) => {
214+ const yoctoSpinner = vi . mocked ( yoctoSpinnerFactory )
215+
216+ beforeEach ( ( ) => {
217+ vi . clearAllMocks ( )
218+ resetEarlySpinner ( )
219+ mockSpinnerInstance . text = ''
220+ // Reset environment variables
221+ delete process . env . TD_SPINNER
222+ delete process . env . CI
223+ // Mock TTY as true by default
224+ Object . defineProperty ( process . stdout , 'isTTY' , {
225+ value : true ,
226+ configurable : true ,
227+ } )
228+ // Clear process.argv
229+ process . argv = [ 'node' , 'td' ]
230+ } )
231+
232+ afterEach ( ( ) => {
233+ vi . clearAllMocks ( )
234+ resetEarlySpinner ( )
235+ } )
236+
237+ it ( 'should start and stop early spinner' , ( ) => {
238+ startEarlySpinner ( )
239+
240+ expect ( yoctoSpinner ) . toHaveBeenCalledWith ( { text : 'Loading...' } )
241+ expect ( mockSpinnerInstance . start ) . toHaveBeenCalled ( )
242+
243+ stopEarlySpinner ( )
244+ expect ( mockSpinnerInstance . stop ) . toHaveBeenCalled ( )
245+ } )
246+
247+ it ( 'should not start early spinner when not in TTY' , ( ) => {
248+ Object . defineProperty ( process . stdout , 'isTTY' , {
249+ value : false ,
250+ configurable : true ,
251+ } )
252+
253+ startEarlySpinner ( )
254+ expect ( yoctoSpinner ) . not . toHaveBeenCalled ( )
255+ } )
256+
257+ it . each ( [
258+ [ '--json' , [ 'node' , 'td' , 'today' , '--json' ] ] ,
259+ [ '--ndjson' , [ 'node' , 'td' , 'today' , '--ndjson' ] ] ,
260+ [ '--no-spinner' , [ 'node' , 'td' , 'today' , '--no-spinner' ] ] ,
261+ ] ) ( 'should not start early spinner with %s flag' , ( _flagName , argv ) => {
262+ process . argv = argv
263+
264+ startEarlySpinner ( )
265+ expect ( yoctoSpinner ) . not . toHaveBeenCalled ( )
266+ } )
267+
268+ it ( 'should not start early spinner in CI environment' , ( ) => {
269+ process . env . CI = 'true'
270+
271+ startEarlySpinner ( )
272+ expect ( yoctoSpinner ) . not . toHaveBeenCalled ( )
273+ } )
274+
275+ it ( 'should not start early spinner when TD_SPINNER=false' , ( ) => {
276+ process . env . TD_SPINNER = 'false'
277+
278+ startEarlySpinner ( )
279+ expect ( yoctoSpinner ) . not . toHaveBeenCalled ( )
280+ } )
281+
282+ it ( 'should be adopted by LoadingSpinner.start() — reuses instance and updates text' , ( ) => {
283+ startEarlySpinner ( )
284+ vi . clearAllMocks ( )
285+
286+ const spinner = new LoadingSpinner ( )
287+ spinner . start ( { text : 'Loading tasks...' , color : 'blue' } )
288+
289+ // Should NOT have created a new yocto-spinner
290+ expect ( yoctoSpinner ) . not . toHaveBeenCalled ( )
291+ // Should NOT have called .start() again (already running)
292+ expect ( mockSpinnerInstance . start ) . not . toHaveBeenCalled ( )
293+ // Should have updated the text
294+ expect ( mockSpinnerInstance . text ) . toBe ( 'Loading tasks...' )
295+ } )
296+
297+ it ( 'should release back on stop — available for re-adoption by next API call' , ( ) => {
298+ startEarlySpinner ( )
299+ vi . clearAllMocks ( )
300+
301+ // First LoadingSpinner adopts the early spinner
302+ const spinner1 = new LoadingSpinner ( )
303+ spinner1 . start ( { text : 'Loading tasks...' , color : 'blue' } )
304+ expect ( yoctoSpinner ) . not . toHaveBeenCalled ( )
305+
306+ // stop() releases it back instead of actually stopping
307+ spinner1 . stop ( )
308+ expect ( mockSpinnerInstance . stop ) . not . toHaveBeenCalled ( )
309+
310+ // Second LoadingSpinner re-adopts the same instance
311+ const spinner2 = new LoadingSpinner ( )
312+ spinner2 . start ( { text : 'Checking authentication...' , color : 'blue' } )
313+ expect ( yoctoSpinner ) . not . toHaveBeenCalled ( )
314+ expect ( mockSpinnerInstance . start ) . not . toHaveBeenCalled ( )
315+ expect ( mockSpinnerInstance . text ) . toBe ( 'Checking authentication...' )
316+
317+ // Final cleanup via stopEarlySpinner actually stops it
318+ spinner2 . stop ( )
319+ stopEarlySpinner ( )
320+ expect ( mockSpinnerInstance . stop ) . toHaveBeenCalledTimes ( 1 )
321+ } )
322+
323+ it ( 'should actually stop on fail even if adopted' , ( ) => {
324+ startEarlySpinner ( )
325+ vi . clearAllMocks ( )
326+
327+ const spinner = new LoadingSpinner ( )
328+ spinner . start ( { text : 'Loading tasks...' , color : 'blue' } )
329+ spinner . fail ( 'Request failed' )
330+
331+ expect ( mockSpinnerInstance . error ) . toHaveBeenCalled ( )
332+ // Should not be released back — error terminates the spinner
333+ stopEarlySpinner ( )
334+ expect ( mockSpinnerInstance . stop ) . not . toHaveBeenCalled ( )
335+ } )
336+
337+ it ( 'should auto-stop when stdout is written to' , ( ) => {
338+ startEarlySpinner ( )
339+ expect ( mockSpinnerInstance . start ) . toHaveBeenCalled ( )
340+
341+ // Simulate command output — spinner should auto-clear
342+ process . stdout . write ( 'output\n' )
343+ expect ( mockSpinnerInstance . stop ) . toHaveBeenCalled ( )
344+ } )
345+
346+ it ( 'should be cleaned up by stopEarlySpinner if never adopted' , ( ) => {
347+ startEarlySpinner ( )
348+ expect ( mockSpinnerInstance . start ) . toHaveBeenCalled ( )
349+
350+ stopEarlySpinner ( )
351+ expect ( mockSpinnerInstance . stop ) . toHaveBeenCalled ( )
352+
353+ // Subsequent stop should be a no-op
354+ vi . clearAllMocks ( )
355+ stopEarlySpinner ( )
356+ expect ( mockSpinnerInstance . stop ) . not . toHaveBeenCalled ( )
357+ } )
358+ } )
0 commit comments