diff --git a/.changeset/dirty-papayas-happen.md b/.changeset/dirty-papayas-happen.md new file mode 100644 index 00000000..68c80675 --- /dev/null +++ b/.changeset/dirty-papayas-happen.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": patch +--- + +Exposes a new `SpinnerResult` type to describe the return type of `spinner` diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index ed2be58d..6587191c 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,7 +1,8 @@ import { stdin, stdout } from 'node:process'; import type { Key } from 'node:readline'; import * as readline from 'node:readline'; -import type { Readable } from 'node:stream'; +import type { Readable, Writable } from 'node:stream'; +import { ReadStream } from 'node:tty'; import { cursor } from 'sisteransi'; import { isActionKey } from './settings.js'; @@ -22,12 +23,19 @@ export function setRawMode(input: Readable, value: boolean) { if (i.isTTY) i.setRawMode(value); } +export interface BlockOptions { + input?: Readable; + output?: Writable; + overwrite?: boolean; + hideCursor?: boolean; +} + export function block({ input = stdin, output = stdout, overwrite = true, hideCursor = true, -} = {}) { +}: BlockOptions = {}) { const rl = readline.createInterface({ input, output, @@ -35,7 +43,10 @@ export function block({ tabSize: 1, }); readline.emitKeypressEvents(input, rl); - if (input.isTTY) input.setRawMode(true); + + if (input instanceof ReadStream && input.isTTY) { + input.setRawMode(true); + } const clear = (data: Buffer, { name, sequence }: Key) => { const str = String(data); @@ -62,7 +73,9 @@ export function block({ if (hideCursor) output.write(cursor.show); // Prevent Windows specific issues: https://github.com/bombshell-dev/clack/issues/176 - if (input.isTTY && !isWindows) input.setRawMode(false); + if (input instanceof ReadStream && input.isTTY && !isWindows) { + input.setRawMode(false); + } // @ts-expect-error fix for https://github.com/nodejs/node/issues/31762#issuecomment-1441223907 rl.terminal = false; diff --git a/packages/prompts/package.json b/packages/prompts/package.json index 97e525e4..553d26b4 100644 --- a/packages/prompts/package.json +++ b/packages/prompts/package.json @@ -46,7 +46,8 @@ "packageManager": "pnpm@8.6.12", "scripts": { "build": "unbuild", - "prepack": "pnpm build" + "prepack": "pnpm build", + "test": "vitest run" }, "dependencies": { "@clack/core": "workspace:*", @@ -54,6 +55,7 @@ "sisteransi": "^1.0.5" }, "devDependencies": { - "is-unicode-supported": "^1.3.0" + "is-unicode-supported": "^1.3.0", + "vitest": "^1.6.0" } } diff --git a/packages/prompts/src/__snapshots__/index.test.ts.snap b/packages/prompts/src/__snapshots__/index.test.ts.snap new file mode 100644 index 00000000..9b51db39 --- /dev/null +++ b/packages/prompts/src/__snapshots__/index.test.ts.snap @@ -0,0 +1,105 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`spinner > message > sets message for next frame 1`] = ` +[ + "[?25l", + "│ +", + "◒ ", + "", + "", + "◐ foo", +] +`; + +exports[`spinner > start > renders frames at interval 1`] = ` +[ + "[?25l", + "│ +", + "◒ ", + "", + "", + "◐ ", + "", + "", + "◓ ", + "", + "", + "◑ ", +] +`; + +exports[`spinner > start > renders message 1`] = ` +[ + "[?25l", + "│ +", + "◒ foo", +] +`; + +exports[`spinner > start > renders timer when indicator is "timer" 1`] = ` +[ + "[?25l", + "│ +", + "◒ [0s]", +] +`; + +exports[`spinner > stop > renders cancel symbol if code = 1 1`] = ` +[ + "[?25l", + "│ +", + "◒ ", + "", + "", + "■ +", + "[?25h", +] +`; + +exports[`spinner > stop > renders error symbol if code > 1 1`] = ` +[ + "[?25l", + "│ +", + "◒ ", + "", + "", + "▲ +", + "[?25h", +] +`; + +exports[`spinner > stop > renders message 1`] = ` +[ + "[?25l", + "│ +", + "◒ ", + "", + "", + "◇ foo +", + "[?25h", +] +`; + +exports[`spinner > stop > renders submit symbol and stops spinner 1`] = ` +[ + "[?25l", + "│ +", + "◒ ", + "", + "", + "◇ +", + "[?25h", +] +`; diff --git a/packages/prompts/src/index.test.ts b/packages/prompts/src/index.test.ts new file mode 100644 index 00000000..93eb43d7 --- /dev/null +++ b/packages/prompts/src/index.test.ts @@ -0,0 +1,142 @@ +import { Writable } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as prompts from './index.js'; + +// TODO (43081j): move this into a util? +class MockWritable extends Writable { + public buffer: string[] = []; + + _write( + chunk: any, + _encoding: BufferEncoding, + callback: (error?: Error | null | undefined) => void + ): void { + this.buffer.push(chunk.toString()); + callback(); + } +} + +describe('spinner', () => { + let output: MockWritable; + + beforeEach(() => { + vi.useFakeTimers(); + output = new MockWritable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + test('returns spinner API', () => { + const api = prompts.spinner({ output }); + + expect(api.stop).toBeTypeOf('function'); + expect(api.start).toBeTypeOf('function'); + expect(api.message).toBeTypeOf('function'); + }); + + describe('start', () => { + test('renders frames at interval', () => { + const result = prompts.spinner({ output }); + + result.start(); + + // there are 4 frames + for (let i = 0; i < 4; i++) { + vi.advanceTimersByTime(80); + } + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders message', () => { + const result = prompts.spinner({ output }); + + result.start('foo'); + + vi.advanceTimersByTime(80); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders timer when indicator is "timer"', () => { + const result = prompts.spinner({ output, indicator: 'timer' }); + + result.start(); + + vi.advanceTimersByTime(80); + + expect(output.buffer).toMatchSnapshot(); + }); + }); + + describe('stop', () => { + test('renders submit symbol and stops spinner', () => { + const result = prompts.spinner({ output }); + + result.start(); + + vi.advanceTimersByTime(80); + + result.stop(); + + vi.advanceTimersByTime(80); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders cancel symbol if code = 1', () => { + const result = prompts.spinner({ output }); + + result.start(); + + vi.advanceTimersByTime(80); + + result.stop('', 1); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders error symbol if code > 1', () => { + const result = prompts.spinner({ output }); + + result.start(); + + vi.advanceTimersByTime(80); + + result.stop('', 2); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders message', () => { + const result = prompts.spinner({ output }); + + result.start(); + + vi.advanceTimersByTime(80); + + result.stop('foo'); + + expect(output.buffer).toMatchSnapshot(); + }); + }); + + describe('message', () => { + test('sets message for next frame', () => { + const result = prompts.spinner({ output }); + + result.start(); + + vi.advanceTimersByTime(80); + + result.message('foo'); + + vi.advanceTimersByTime(80); + + expect(output.buffer).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 01cd736b..8e465314 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -1,3 +1,4 @@ +import type { Writable } from 'node:stream'; import { stripVTControlCharacters as strip } from 'node:util'; import { ConfirmPrompt, @@ -727,9 +728,19 @@ export const stream = { export interface SpinnerOptions { indicator?: 'dots' | 'timer'; + output?: Writable; } -export const spinner = ({ indicator = 'dots' }: SpinnerOptions = {}) => { +export interface SpinnerResult { + start(msg?: string): void; + stop(msg?: string, code?: number): void; + message(msg?: string): void; +} + +export const spinner = ({ + indicator = 'dots', + output = process.stdout, +}: SpinnerOptions = {}): SpinnerResult => { const frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0']; const delay = unicode ? 80 : 120; const isCI = process.env.CI === 'true'; @@ -770,10 +781,10 @@ export const spinner = ({ indicator = 'dots' }: SpinnerOptions = {}) => { const clearPrevMessage = () => { if (_prevMessage === undefined) return; - if (isCI) process.stdout.write('\n'); + if (isCI) output.write('\n'); const prevLines = _prevMessage.split('\n'); - process.stdout.write(cursor.move(-999, prevLines.length - 1)); - process.stdout.write(erase.down(prevLines.length)); + output.write(cursor.move(-999, prevLines.length - 1)); + output.write(erase.down(prevLines.length)); }; const parseMessage = (msg: string): string => { @@ -789,10 +800,10 @@ export const spinner = ({ indicator = 'dots' }: SpinnerOptions = {}) => { const start = (msg = ''): void => { isSpinnerActive = true; - unblock = block(); + unblock = block({ output }); _message = parseMessage(msg); _origin = performance.now(); - process.stdout.write(`${color.gray(S_BAR)}\n`); + output.write(`${color.gray(S_BAR)}\n`); let frameIndex = 0; let indicatorTimer = 0; registerHooks(); @@ -805,12 +816,12 @@ export const spinner = ({ indicator = 'dots' }: SpinnerOptions = {}) => { const frame = color.magenta(frames[frameIndex]); if (isCI) { - process.stdout.write(`${frame} ${_message}...`); + output.write(`${frame} ${_message}...`); } else if (indicator === 'timer') { - process.stdout.write(`${frame} ${_message} ${formatTimer(_origin)}`); + output.write(`${frame} ${_message} ${formatTimer(_origin)}`); } else { const loadingDots = '.'.repeat(Math.floor(indicatorTimer)).slice(0, 3); - process.stdout.write(`${frame} ${_message}${loadingDots}`); + output.write(`${frame} ${_message}${loadingDots}`); } frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0; @@ -830,9 +841,9 @@ export const spinner = ({ indicator = 'dots' }: SpinnerOptions = {}) => { : color.red(S_STEP_ERROR); _message = parseMessage(msg ?? _message); if (indicator === 'timer') { - process.stdout.write(`${step} ${_message} ${formatTimer(_origin)}\n`); + output.write(`${step} ${_message} ${formatTimer(_origin)}\n`); } else { - process.stdout.write(`${step} ${_message}\n`); + output.write(`${step} ${_message}\n`); } clearHooks(); unblock(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da2a1e3c..3c92ac12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: is-unicode-supported: specifier: ^1.3.0 version: 1.3.0 + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@18.16.0) packages: @@ -5369,4 +5372,3 @@ snapshots: yocto-queue@0.1.0: {} yocto-queue@1.1.1: {} - \ No newline at end of file