diff --git a/.changeset/plain-ci-spinner-progress.md b/.changeset/plain-ci-spinner-progress.md new file mode 100644 index 00000000..d3ef8d4b --- /dev/null +++ b/.changeset/plain-ci-spinner-progress.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": minor +--- + +In CI and non-TTY environments, `spinner` and `progress` print plain one-line updates instead of animated frames (see #629). diff --git a/packages/prompts/src/common.ts b/packages/prompts/src/common.ts index 88037f0b..242b9b85 100644 --- a/packages/prompts/src/common.ts +++ b/packages/prompts/src/common.ts @@ -8,6 +8,15 @@ export const isCI = (): boolean => process.env.CI === 'true'; export const isTTY = (output: Writable): boolean => { return (output as Writable & { isTTY?: boolean }).isTTY === true; }; + +/** + * Whether live components (`spinner`, `progress`) should print plain lines + * instead of TTY frames / cursor control sequences. + * + * Follows the same CI gate already used by spinner, and also treats non-TTY + * outputs as plain (aligned with `taskLog`'s `!isCI && isTTY` live path). + */ +export const isPlainLiveOutput = (output: Writable): boolean => isCI() || !isTTY(output); export const unicodeOr = (c: string, fallback: string) => (unicode ? c : fallback); export const S_STEP_ACTIVE = unicodeOr('◆', '*'); export const S_STEP_CANCEL = unicodeOr('■', 'x'); diff --git a/packages/prompts/src/progress-bar.ts b/packages/prompts/src/progress-bar.ts index 713524cc..2d98352d 100644 --- a/packages/prompts/src/progress-bar.ts +++ b/packages/prompts/src/progress-bar.ts @@ -1,6 +1,6 @@ import { styleText } from 'node:util'; import type { State } from '@clack/core'; -import { unicodeOr } from './common.js'; +import { isPlainLiveOutput, unicodeOr } from './common.js'; import { type SpinnerOptions, type SpinnerResult, spinner } from './spinner.js'; const S_PROGRESS_CHAR: Record, string> = { @@ -23,9 +23,11 @@ export function progress({ style = 'heavy', max: userMax = 100, size: userSize = 40, + output = process.stdout, ...spinnerOptions }: ProgressOptions = {}): ProgressResult { - const spin = spinner(spinnerOptions); + const plain = isPlainLiveOutput(output); + const spin = spinner({ ...spinnerOptions, output }); let value = 0; let previousMessage = ''; @@ -53,12 +55,24 @@ export function progress({ const start = (msg = '') => { previousMessage = msg; - spin.start(drawProgress('initial', msg)); + if (plain) { + spin.start(msg); + } else { + spin.start(drawProgress('initial', msg)); + } }; const advance = (step = 1, msg?: string): void => { value = Math.min(max, step + value); - spin.message(drawProgress('active', msg ?? previousMessage)); - previousMessage = msg ?? previousMessage; + const nextMessage = msg ?? previousMessage; + if (plain) { + // Only emit when the human-readable message changes — bar ticks stay silent in CI. + if (msg !== undefined) { + spin.message(msg); + } + } else { + spin.message(drawProgress('active', nextMessage)); + } + previousMessage = nextMessage; }; return { start, diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 258cff35..f10d268f 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -4,7 +4,7 @@ import { wrapAnsi } from 'fast-wrap-ansi'; import { cursor, erase } from 'sisteransi'; import { type CommonOptions, - isCI as isCIFn, + isPlainLiveOutput, S_BAR, S_STEP_CANCEL, S_STEP_ERROR, @@ -20,6 +20,12 @@ export interface SpinnerOptions extends CommonOptions { frames?: string[]; delay?: number; styleFrame?: (frame: string) => string; + /** + * When plain/CI output is active, print a new line each time `message()` + * changes. Set to `false` to only print the start and final status lines. + * @default true + */ + plainMessageUpdates?: boolean; } export interface SpinnerResult { @@ -45,13 +51,14 @@ export const spinner = ({ frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'], delay = unicode ? 80 : 120, signal, + plainMessageUpdates = true, ...opts }: SpinnerOptions = {}): SpinnerResult => { - const isCI = isCIFn(); + const plain = isPlainLiveOutput(output); const accessible = isAccessible(opts.accessible); let unblock: (() => void) | undefined; - let loop: NodeJS.Timeout; + let loop: NodeJS.Timeout | undefined; let isSpinnerActive = false; let isCancelled = false; let _message = ''; @@ -106,7 +113,6 @@ export const spinner = ({ const clearPrevMessage = () => { if (_prevMessage === undefined) return; - if (isCI) output.write('\n'); const wrapped = wrapAnsi(_prevMessage, columns, { hard: true, trim: false, @@ -130,12 +136,27 @@ export const spinner = ({ return min > 0 ? `[${min}m ${secs}s]` : `[${secs}s]`; }; + const writePlainStatus = (code: number, msg: string): void => { + const step = + code === 0 + ? styleText('green', S_STEP_SUBMIT) + : code === 1 + ? styleText('red', S_STEP_CANCEL) + : styleText('red', S_STEP_ERROR); + if (indicator === 'timer') { + output.write(`${step} ${msg} ${formatTimer(_origin)}\n`); + } else { + output.write(`${step} ${msg}\n`); + } + }; + const hasGuide = opts.withGuide ?? settings.withGuide; const start = (msg = ''): void => { isSpinnerActive = true; isCancelled = false; _message = removeTrailingDots(msg); + _prevMessage = undefined; _origin = performance.now(); registerHooks(); if (accessible) { @@ -147,6 +168,14 @@ export const spinner = ({ }, ACCESSIBLE_HEARTBEAT_MS); return; } + if (plain) { + // Plain CI / non-TTY: one start line, no cursor hide, no frames. + if (_message !== '') { + output.write(`${_message}\n`); + } + _prevMessage = _message; + return; + } unblock = block({ output }); if (hasGuide) { output.write(`${styleText('gray', S_BAR)}\n`); @@ -154,18 +183,13 @@ export const spinner = ({ let frameIndex = 0; let indicatorTimer = 0; loop = setInterval(() => { - if (isCI && _message === _prevMessage) { - return; - } clearPrevMessage(); _prevMessage = _message; // biome-ignore lint/style/noNonNullAssertion: frameIndex is always kept within frames bounds const frame = styleFn(frames[frameIndex]!); let outputMessage: string; - if (isCI) { - outputMessage = `${frame} ${_message}...`; - } else if (indicator === 'timer') { + if (indicator === 'timer') { outputMessage = `${frame} ${_message} ${formatTimer(_origin)}`; } else { const loadingDots = '.'.repeat(Math.floor(indicatorTimer)).slice(0, 3); @@ -188,8 +212,11 @@ export const spinner = ({ if (!isSpinnerActive) return; isSpinnerActive = false; isCancelled = code === 1; - clearInterval(loop); - if (!accessible) { + if (loop !== undefined) { + clearInterval(loop); + loop = undefined; + } + if (!accessible && !plain) { clearPrevMessage(); } _message = msg ?? _message; @@ -207,6 +234,8 @@ export const spinner = ({ } else { output.write(`${finalMessage}\n`); } + } else if (plain) { + writePlainStatus(code, _message); } else { const step = code === 0 @@ -234,7 +263,19 @@ export const spinner = ({ const clear = (): void => _stop('', 0, true); const message = (msg = ''): void => { - _message = removeTrailingDots(msg ?? _message); + const next = removeTrailingDots(msg ?? _message); + if ( + plain && + !accessible && + isSpinnerActive && + plainMessageUpdates && + next !== _message && + next !== '' + ) { + output.write(`${next}\n`); + _prevMessage = next; + } + _message = next; }; return { diff --git a/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap b/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap index 8296f517..0afab278e 100644 --- a/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap +++ b/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap @@ -294,293 +294,151 @@ exports[`prompts - progress (isCI = false) > style > renders light progressbar 1 exports[`prompts - progress (isCI = true) > message > sets message for next frame 1`] = ` [ - "", - "│ + "foo ", - "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", - " -", - "", - "", - "◐ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ foo...", ] `; exports[`prompts - progress (isCI = true) > process exit handling > prioritizes cancel option over global setting 1`] = ` [ - "", - "│ + "Test operation ", "■ Progress cancel message ", - "", ] `; exports[`prompts - progress (isCI = true) > process exit handling > prioritizes error option over global setting 1`] = ` [ - "", - "│ + "Test operation ", "▲ Progress error message ", - "", ] `; exports[`prompts - progress (isCI = true) > process exit handling > uses custom cancel message when provided directly 1`] = ` [ - "", - "│ + "Test operation ", "■ Custom cancel message ", - "", ] `; exports[`prompts - progress (isCI = true) > process exit handling > uses custom error message when provided directly 1`] = ` [ - "", - "│ + "Test operation ", "▲ Custom error message ", - "", ] `; exports[`prompts - progress (isCI = true) > process exit handling > uses default cancel message 1`] = ` [ - "", - "│ + "Test operation ", "■ Canceled ", - "", ] `; exports[`prompts - progress (isCI = true) > process exit handling > uses global custom cancel message from settings 1`] = ` [ - "", - "│ + "Test operation ", "■ Global cancel message ", - "", ] `; exports[`prompts - progress (isCI = true) > process exit handling > uses global custom error message from settings 1`] = ` [ - "", - "│ + "Test operation ", "▲ Global error message ", - "", ] `; -exports[`prompts - progress (isCI = true) > start > renders frames at interval 1`] = ` -[ - "", - "│ -", - "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", -] -`; +exports[`prompts - progress (isCI = true) > start > renders frames at interval 1`] = `[]`; exports[`prompts - progress (isCI = true) > start > renders message 1`] = ` [ - "", - "│ + "foo ", - "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ foo...", ] `; -exports[`prompts - progress (isCI = true) > start > renders timer when indicator is "timer" 1`] = ` -[ - "", - "│ -", - "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", -] -`; +exports[`prompts - progress (isCI = true) > start > renders timer when indicator is "timer" 1`] = `[]`; exports[`prompts - progress (isCI = true) > stop > renders cancel symbol when calling cancel() 1`] = ` [ - "", - "│ -", - "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", - " -", - "", - "", "■ ", - "", ] `; exports[`prompts - progress (isCI = true) > stop > renders error symbol when calling error() 1`] = ` [ - "", - "│ -", - "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", - " -", - "", - "", "▲ ", - "", ] `; exports[`prompts - progress (isCI = true) > stop > renders message 1`] = ` [ - "", - "│ -", - "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", - " -", - "", - "", "◇ foo ", - "", ] `; exports[`prompts - progress (isCI = true) > stop > renders message when cancelling 1`] = ` [ - "", - "│ -", - "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", - " -", - "", - "", "■ cancelled :-( ", - "", ] `; exports[`prompts - progress (isCI = true) > stop > renders message when erroring 1`] = ` [ - "", - "│ -", - "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", - " -", - "", - "", "▲ FATAL ERROR! ", - "", ] `; exports[`prompts - progress (isCI = true) > stop > renders message without removing dots 1`] = ` [ - "", - "│ -", - "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", - " -", - "", - "", "◇ foo. ", - "", ] `; exports[`prompts - progress (isCI = true) > stop > renders submit symbol and stops progress 1`] = ` [ - "", - "│ -", - "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", - " -", - "", - "", "◇ ", - "", ] `; exports[`prompts - progress (isCI = true) > style > renders block progressbar 1`] = ` [ - "", - "│ -", - "◒ ██████████ ...", - " -", - "", - "", - "◐ ██████████ ...", - " -", - "", - "", "◇ ", - "", ] `; exports[`prompts - progress (isCI = true) > style > renders heavy progressbar 1`] = ` [ - "", - "│ -", - "◒ ━━━━━━━━━━ ...", - " -", - "", - "", - "◐ ━━━━━━━━━━ ...", - " -", - "", - "", "◇ ", - "", ] `; exports[`prompts - progress (isCI = true) > style > renders light progressbar 1`] = ` [ - "", - "│ -", - "◒ ────────── ...", - " -", - "", - "", - "◐ ────────── ...", - " -", - "", - "", "◇ ", - "", ] `; diff --git a/packages/prompts/test/__snapshots__/spinner.test.ts.snap b/packages/prompts/test/__snapshots__/spinner.test.ts.snap index 31412ea1..6bd80e5b 100644 --- a/packages/prompts/test/__snapshots__/spinner.test.ts.snap +++ b/packages/prompts/test/__snapshots__/spinner.test.ts.snap @@ -597,412 +597,226 @@ exports[`spinner (isCI = false) > withGuide: false removes guide 1`] = ` exports[`spinner (isCI = true) > can be aborted by a signal 1`] = ` [ - "", - "│ + "Testing ", "■ Canceled ", - "", ] `; exports[`spinner (isCI = true) > clear > stops and clears the spinner from the output 1`] = ` [ - "", - "│ -", - "◒ Loading...", - " + "Loading ", - "", - "", - "", ] `; exports[`spinner (isCI = true) > global withGuide: false removes guide 1`] = ` [ - "", - "◒ foo...", - " + "foo ", - "", - "", "◇ ", - "", ] `; exports[`spinner (isCI = true) > indicator customization > custom delay 1`] = ` [ - "", - "│ -", - "◒ ...", - " -", - "", - "", "◇ ", - "", ] `; exports[`spinner (isCI = true) > indicator customization > custom frame style 1`] = ` [ - "", - "│ -", - "◒ ...", - " -", - "", - "", "◇ ", - "", ] `; exports[`spinner (isCI = true) > indicator customization > custom frames 1`] = ` [ - "", - "│ -", - "🐴 ...", - " -", - "", - "", "◇ ", - "", ] `; exports[`spinner (isCI = true) > indicator customization > custom frames with lots of frame have consistent ellipsis display 1`] = ` [ - "", - "│ -", - "0 ...", - " -", - "", - "", "◇ ", - "", ] `; exports[`spinner (isCI = true) > message > sets message for next frame 1`] = ` [ - "", - "│ -", - "◒ ...", - " -", - "", - "", - "◐ foo...", - " + "foo ", - "", - "", "◇ ", - "", ] `; exports[`spinner (isCI = true) > process exit handling > prioritizes cancel option over global setting 1`] = ` [ - "", - "│ + "Test operation ", "■ Spinner cancel message ", - "", ] `; exports[`spinner (isCI = true) > process exit handling > prioritizes error option over global setting 1`] = ` [ - "", - "│ + "Test operation ", "▲ Spinner error message ", - "", ] `; exports[`spinner (isCI = true) > process exit handling > uses custom cancel message when provided directly 1`] = ` [ - "", - "│ + "Test operation ", "■ Custom cancel message ", - "", ] `; exports[`spinner (isCI = true) > process exit handling > uses custom error message when provided directly 1`] = ` [ - "", - "│ + "Test operation ", "▲ Custom error message ", - "", ] `; exports[`spinner (isCI = true) > process exit handling > uses default cancel message 1`] = ` [ - "", - "│ + "Test operation ", "■ Canceled ", - "", ] `; exports[`spinner (isCI = true) > process exit handling > uses global custom cancel message from settings 1`] = ` [ - "", - "│ + "Test operation ", "■ Global cancel message ", - "", ] `; exports[`spinner (isCI = true) > process exit handling > uses global custom error message from settings 1`] = ` [ - "", - "│ + "Test operation ", "▲ Global error message ", - "", ] `; exports[`spinner (isCI = true) > start > handles multi-line messages 1`] = ` [ - "", - "│ -", - "◒ foo + "foo bar -baz...", - " +baz ", - "", - "", - "", "◇ ", - "", ] `; exports[`spinner (isCI = true) > start > handles wrapping 1`] = ` [ - "", - "│ -", - "◒ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -xxxxxxxxxxxxx...", - " + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ", - "", - "", - "", "◇ stopped ", - "", ] `; exports[`spinner (isCI = true) > start > renders frames at interval 1`] = ` [ - "", - "│ -", - "◒ ...", - " -", - "", - "", "◇ ", - "", ] `; exports[`spinner (isCI = true) > start > renders message 1`] = ` [ - "", - "│ -", - "◒ foo...", - " + "foo ", - "", - "", "◇ ", - "", ] `; exports[`spinner (isCI = true) > start > renders timer when indicator is "timer" 1`] = ` [ - "", - "│ -", - "◒ ...", - " -", - "", - "", "◇ [0s] ", - "", ] `; exports[`spinner (isCI = true) > stop > renders cancel symbol when calling cancel() 1`] = ` [ - "", - "│ -", - "◒ ...", - " -", - "", - "", "■ ", - "", ] `; exports[`spinner (isCI = true) > stop > renders error symbol when calling error() 1`] = ` [ - "", - "│ -", - "◒ ...", - " -", - "", - "", "▲ ", - "", ] `; exports[`spinner (isCI = true) > stop > renders message 1`] = ` [ - "", - "│ -", - "◒ ...", - " -", - "", - "", "◇ foo ", - "", ] `; exports[`spinner (isCI = true) > stop > renders message when cancelling 1`] = ` [ - "", - "│ -", - "◒ ...", - " -", - "", - "", "■ too dizzy — spinning cancelled ", - "", ] `; exports[`spinner (isCI = true) > stop > renders message when erroring 1`] = ` [ - "", - "│ -", - "◒ ...", - " -", - "", - "", "▲ error: spun too fast! ", - "", ] `; exports[`spinner (isCI = true) > stop > renders message without removing dots 1`] = ` [ - "", - "│ -", - "◒ ...", - " -", - "", - "", "◇ foo. ", - "", ] `; exports[`spinner (isCI = true) > stop > renders submit symbol and stops spinner 1`] = ` [ - "", - "│ -", - "◒ ...", - " -", - "", - "", "◇ ", - "", ] `; exports[`spinner (isCI = true) > withGuide: false removes guide 1`] = ` [ - "", - "◒ foo...", - " + "foo ", - "", - "", "◇ ", - "", ] `; diff --git a/packages/prompts/test/progress-bar.test.ts b/packages/prompts/test/progress-bar.test.ts index 377930ca..c64a71bb 100644 --- a/packages/prompts/test/progress-bar.test.ts +++ b/packages/prompts/test/progress-bar.test.ts @@ -20,6 +20,7 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { beforeEach(() => { output = new MockWritable(); + output.isTTY = true; vi.useFakeTimers(); }); diff --git a/packages/prompts/test/spinner-accessible.test.ts b/packages/prompts/test/spinner-accessible.test.ts index 9c2eace3..d80478eb 100644 --- a/packages/prompts/test/spinner-accessible.test.ts +++ b/packages/prompts/test/spinner-accessible.test.ts @@ -96,11 +96,17 @@ describe('spinner (accessible)', () => { test('accessible: false option overrides the global setting', () => { updateSettings({ accessible: true }); + output.isTTY = true; const result = prompts.spinner({ output, accessible: false }); result.start('Loading'); + vi.advanceTimersByTime(80); result.stop('Done'); expect(output.buffer.join('')).toMatch(ANSI_REGEX); + // Raw cursor-hide from block() (serializer only rewrites snapshot values). + expect( + output.buffer.some((chunk) => chunk.includes('\x1b[?25l') || chunk === '') + ).toBe(true); }); }); diff --git a/packages/prompts/test/spinner-plain.test.ts b/packages/prompts/test/spinner-plain.test.ts new file mode 100644 index 00000000..39f19abc --- /dev/null +++ b/packages/prompts/test/spinner-plain.test.ts @@ -0,0 +1,133 @@ +import { styleText } from 'node:util'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { S_STEP_CANCEL, S_STEP_ERROR, S_STEP_SUBMIT } from '../src/common.js'; +import * as prompts from '../src/index.js'; +import { MockWritable } from './test-utils.js'; + +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching cursor/erase sequences +const CURSOR_OR_ERASE = /\x1b\[\?25[lh]|\x1b\[[0-9]*[GJKA]| { + process.env.CI = 'true'; + output.isTTY = true; + }, + }, + { + label: 'non-TTY', + setup: (output: MockWritable) => { + process.env.CI = 'false'; + output.isTTY = false; + }, + }, +])('spinner plain output ($label)', ({ setup }) => { + let originalCI: string | undefined; + let output: MockWritable; + + beforeEach(() => { + originalCI = process.env.CI; + output = new MockWritable(); + setup(output); + vi.useFakeTimers(); + }); + + afterEach(() => { + process.env.CI = originalCI; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test('prints start and status lines without cursor or spinner frames', () => { + const result = prompts.spinner({ output }); + + result.start('Publishing packages'); + vi.advanceTimersByTime(500); + result.message('Still publishing'); + vi.advanceTimersByTime(500); + result.stop('Successfully published'); + + const joined = output.buffer.join(''); + expect(joined).not.toMatch(CURSOR_OR_ERASE); + expect(joined).not.toMatch(/[◒◐◓◑]/); + expect(output.buffer).toEqual([ + 'Publishing packages\n', + 'Still publishing\n', + `${styleText('green', S_STEP_SUBMIT)} Successfully published\n`, + ]); + }); + + test('cancel and error use status symbols without TTY junk', () => { + for (const [end, step, msg] of [ + ['cancel', styleText('red', S_STEP_CANCEL), 'Cancelled'] as const, + ['error', styleText('red', S_STEP_ERROR), 'Failed'] as const, + ]) { + output = new MockWritable(); + setup(output); + const result = prompts.spinner({ output }); + result.start('Working'); + result[end](msg); + expect(output.buffer.join('')).not.toMatch(CURSOR_OR_ERASE); + expect(output.buffer).toEqual(['Working\n', `${step} ${msg}\n`]); + } + }); + + test('plainMessageUpdates: false only prints start and end', () => { + const result = prompts.spinner({ output, plainMessageUpdates: false }); + + result.start('Start'); + result.message('Mid'); + result.message('Later'); + result.stop('Done'); + + expect(output.buffer).toEqual(['Start\n', `${styleText('green', S_STEP_SUBMIT)} Done\n`]); + }); + + test('clear is silent', () => { + const result = prompts.spinner({ output }); + result.start('Loading'); + result.clear(); + expect(output.buffer).toEqual(['Loading\n']); + }); +}); + +describe('progress plain output (CI=true)', () => { + let originalCI: string | undefined; + let output: MockWritable; + + beforeEach(() => { + originalCI = process.env.CI; + process.env.CI = 'true'; + output = new MockWritable(); + output.isTTY = true; + vi.useFakeTimers(); + }); + + afterEach(() => { + process.env.CI = originalCI; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test('omits progress bar and cursor codes; advance without message is silent', () => { + const result = prompts.progress({ output, max: 4, size: 10 }); + + result.start('Publishing packages'); + result.advance(); + result.advance(); + result.message('Creating git tags'); + result.advance(); + result.stop('Created git tags.'); + + const joined = output.buffer.join(''); + expect(joined).not.toMatch(CURSOR_OR_ERASE); + expect(joined).not.toMatch(/[━─█=#-]{4,}/); + expect(joined).not.toMatch(/[◒◐◓◑]/); + expect(output.buffer).toEqual([ + 'Publishing packages\n', + 'Creating git tags\n', + `${styleText('green', S_STEP_SUBMIT)} Created git tags.\n`, + ]); + }); +}); diff --git a/packages/prompts/test/spinner.test.ts b/packages/prompts/test/spinner.test.ts index e23dd66c..14830d26 100644 --- a/packages/prompts/test/spinner.test.ts +++ b/packages/prompts/test/spinner.test.ts @@ -20,6 +20,8 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { beforeEach(() => { output = new MockWritable(); + // Animated path requires a TTY; plain/CI mode uses isPlainLiveOutput(). + output.isTTY = true; vi.useFakeTimers(); });