From b72fe57600cad39476e44a58f03f8f424db2552d Mon Sep 17 00:00:00 2001 From: Daniel Gaskins Date: Tue, 22 Sep 2026 20:13:42 -0700 Subject: [PATCH 1/2] fix: suppress spinners outside terminals --- src/__tests__/utils/spinner.test.ts | 75 +++++++++++++++++++++++++++++ src/utils/spinner.ts | 7 ++- 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/utils/spinner.test.ts diff --git a/src/__tests__/utils/spinner.test.ts b/src/__tests__/utils/spinner.test.ts new file mode 100644 index 0000000000..4182d65f40 --- /dev/null +++ b/src/__tests__/utils/spinner.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSpinner } from '../../utils/spinner'; + +describe('spinner', () => { + let stderrWriteSpy: ReturnType; + let originalIsTTY: boolean | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + originalIsTTY = process.stderr.isTTY; + stderrWriteSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + }); + + afterEach(() => { + Object.defineProperty(process.stderr, 'isTTY', { + configurable: true, + value: originalIsTTY, + }); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + function setInteractive(isInteractive: boolean): void { + Object.defineProperty(process.stderr, 'isTTY', { + configurable: true, + value: isInteractive, + }); + } + + it('animates when stderr is attached to a terminal', () => { + setInteractive(true); + const spinner = createSpinner('Working...'); + + spinner.start(); + + expect(stderrWriteSpy).toHaveBeenNthCalledWith(1, '\r\x1b[K'); + expect(stderrWriteSpy).toHaveBeenNthCalledWith(2, '⠋ Working...'); + + vi.advanceTimersByTime(80); + + expect(stderrWriteSpy).toHaveBeenNthCalledWith(3, '\r\x1b[K'); + expect(stderrWriteSpy).toHaveBeenNthCalledWith(4, '⠙ Working...'); + + spinner.succeed('Done'); + + expect(stderrWriteSpy).toHaveBeenNthCalledWith(5, '\r\x1b[K'); + expect(stderrWriteSpy).toHaveBeenNthCalledWith(6, '✓ Done\n'); + }); + + it('writes only the final status when stderr is redirected', () => { + setInteractive(false); + const spinner = createSpinner('Working...'); + + spinner.start(); + spinner.update('Still working...'); + vi.advanceTimersByTime(240); + spinner.succeed('Done'); + + expect(stderrWriteSpy).toHaveBeenCalledTimes(1); + expect(stderrWriteSpy).toHaveBeenCalledWith('✓ Done\n'); + }); + + it('keeps failure messages in redirected output', () => { + setInteractive(false); + const spinner = createSpinner('Working...'); + + spinner.start(); + spinner.fail('Request failed'); + + expect(stderrWriteSpy).toHaveBeenCalledTimes(1); + expect(stderrWriteSpy).toHaveBeenCalledWith('✗ Request failed\n'); + }); +}); diff --git a/src/utils/spinner.ts b/src/utils/spinner.ts index f6c07e7f09..89c37b1fbc 100644 --- a/src/utils/spinner.ts +++ b/src/utils/spinner.ts @@ -16,6 +16,7 @@ export function createSpinner(initialMessage: string = ''): Spinner { let frameIndex = 0; let interval: ReturnType | null = null; let currentMessage = initialMessage; + const isInteractive = process.stderr.isTTY === true; const clearLine = () => { process.stderr.write('\r\x1b[K'); @@ -31,7 +32,7 @@ export function createSpinner(initialMessage: string = ''): Spinner { return { start(message?: string) { if (message) currentMessage = message; - if (interval) return; + if (!isInteractive || interval) return; render(); interval = setInterval(render, 80); }, @@ -45,7 +46,9 @@ export function createSpinner(initialMessage: string = ''): Spinner { clearInterval(interval); interval = null; } - clearLine(); + if (isInteractive) { + clearLine(); + } if (finalMessage) { process.stderr.write(`${finalMessage}\n`); } From 75ad066b4ee07f0dfdcd65a60a1553fa1c681c70 Mon Sep 17 00:00:00 2001 From: Daniel Gaskins Date: Wed, 23 Sep 2026 10:13:50 -0700 Subject: [PATCH 2/2] Test spinner cleanup and redirected status fallback --- src/__tests__/utils/spinner.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/__tests__/utils/spinner.test.ts b/src/__tests__/utils/spinner.test.ts index 4182d65f40..2f779f60d4 100644 --- a/src/__tests__/utils/spinner.test.ts +++ b/src/__tests__/utils/spinner.test.ts @@ -47,6 +47,9 @@ describe('spinner', () => { expect(stderrWriteSpy).toHaveBeenNthCalledWith(5, '\r\x1b[K'); expect(stderrWriteSpy).toHaveBeenNthCalledWith(6, '✓ Done\n'); + + vi.advanceTimersByTime(200); + expect(stderrWriteSpy).toHaveBeenCalledTimes(6); }); it('writes only the final status when stderr is redirected', () => { @@ -72,4 +75,16 @@ describe('spinner', () => { expect(stderrWriteSpy).toHaveBeenCalledTimes(1); expect(stderrWriteSpy).toHaveBeenCalledWith('✗ Request failed\n'); }); + + it('uses the latest message as the redirected final status', () => { + setInteractive(false); + const spinner = createSpinner('Working...'); + + spinner.start(); + spinner.update('Still working...'); + spinner.succeed(); + + expect(stderrWriteSpy).toHaveBeenCalledTimes(1); + expect(stderrWriteSpy).toHaveBeenCalledWith('✓ Still working...\n'); + }); });