Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/__tests__/utils/spinner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSpinner } from '../../utils/spinner';

describe('spinner', () => {
let stderrWriteSpy: ReturnType<typeof vi.spyOn>;
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');
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

expect(stderrWriteSpy).toHaveBeenNthCalledWith(5, '\r\x1b[K');
expect(stderrWriteSpy).toHaveBeenNthCalledWith(6, '✓ Done\n');
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

vi.advanceTimersByTime(200);
expect(stderrWriteSpy).toHaveBeenCalledTimes(6);
});

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');
});

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');
});
});
7 changes: 5 additions & 2 deletions src/utils/spinner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function createSpinner(initialMessage: string = ''): Spinner {
let frameIndex = 0;
let interval: ReturnType<typeof setInterval> | null = null;
let currentMessage = initialMessage;
const isInteractive = process.stderr.isTTY === true;

const clearLine = () => {
process.stderr.write('\r\x1b[K');
Expand All @@ -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);
},
Expand All @@ -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`);
}
Expand Down