/** * use-spinner.test.tsx — Tests for the useSpinner hook. * * Verifies: * - Hook increments frame counter when active * - Hook stops incrementing when active is false * - Hook returns frame 0 initially */ import { describe, test, expect, mock } from "bun:test"; import React, { useState, useEffect } from "react"; import { render, renderToString, Text } from "ink"; import { useSpinner } from "./use-spinner"; import { PassThrough } from "node:stream"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function createTestStreams() { const stdout = new PassThrough() as unknown as NodeJS.WriteStream; (stdout as any).columns = 80; (stdout as any).rows = 24; const stdin = new PassThrough() as unknown as NodeJS.ReadStream; (stdin as any).isTTY = true; (stdin as any).setRawMode = () => stdin; (stdin as any).ref = () => stdin; (stdin as any).unref = () => stdin; return { stdin, stdout }; } // Test component that uses the hook and calls back with the frame value function SpinnerDisplay({ active, onFrame }: { active: boolean; onFrame: (f: number) => void }) { const frame = useSpinner(active); useEffect(() => { onFrame(frame); }, [frame, onFrame]); return frame:{frame}; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("useSpinner", () => { test("returns initial frame 0", () => { const output = renderToString( {}} /> ); expect(output).toContain("frame:0"); }); test("increments frame when active", async () => { const { stdin, stdout } = createTestStreams(); const frames: number[] = []; const instance = render( frames.push(f)} />, { stdout, stdin, debug: true, exitOnCtrlC: false, patchConsole: false, } ); // Wait for a few spinner ticks (120ms interval) await new Promise((r) => setTimeout(r, 500)); instance.unmount(); await instance.waitUntilExit(); // Should have seen frame 0 and at least one increment expect(frames.length).toBeGreaterThan(1); expect(frames[0]).toBe(0); // Later frames should be > 0 const maxFrame = Math.max(...frames); expect(maxFrame).toBeGreaterThan(0); }); test("stops incrementing when active is false", async () => { const { stdin, stdout } = createTestStreams(); const frames: number[] = []; const instance = render( frames.push(f)} />, { stdout, stdin, debug: true, exitOnCtrlC: false, patchConsole: false, } ); await new Promise((r) => setTimeout(r, 300)); instance.unmount(); await instance.waitUntilExit(); // All frames should be 0 since active=false for (const f of frames) { expect(f).toBe(0); } }); test("respects custom intervalMs parameter", async () => { const { stdin, stdout } = createTestStreams(); const frames: number[] = []; // Use a custom spinner with a very short interval (50ms) function FastSpinner({ onFrame }: { onFrame: (f: number) => void }) { const frame = useSpinner(true, 50); useEffect(() => { onFrame(frame); }, [frame, onFrame]); return frame:{frame}; } const instance = render( frames.push(f)} />, { stdout, stdin, debug: true, exitOnCtrlC: false, patchConsole: false, } ); // Wait 350ms — with 50ms interval, should see ~7 increments await new Promise((r) => setTimeout(r, 350)); instance.unmount(); await instance.waitUntilExit(); // Should have more frames than with the default 120ms interval const maxFrame = Math.max(...frames); expect(maxFrame).toBeGreaterThanOrEqual(4); }); test("cleans up interval on unmount", async () => { const { stdin, stdout } = createTestStreams(); const frames: number[] = []; const instance = render( frames.push(f)} />, { stdout, stdin, debug: true, exitOnCtrlC: false, patchConsole: false, } ); // Let it run briefly await new Promise((r) => setTimeout(r, 200)); // Unmount instance.unmount(); await instance.waitUntilExit(); const frameCountAtUnmount = frames.length; // Wait more — no new frames should appear after unmount await new Promise((r) => setTimeout(r, 300)); expect(frames.length).toBe(frameCountAtUnmount); }); });