[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = tab\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n\n[*.yml]\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf\n"
  },
  {
    "path": ".github/workflows/main.yml",
    "content": "name: CI\non:\n  - push\n  - pull_request\njobs:\n  test:\n    name: Node.js ${{ matrix.node-version }}\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        node-version:\n          - 22\n          - 20\n          - 18\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: ${{ matrix.node-version }}\n      - run: npm install\n      - run: npm test\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules\nyarn.lock\n"
  },
  {
    "path": ".npmrc",
    "content": "package-lock=false\n"
  },
  {
    "path": "example-console-integration.js",
    "content": "import {\n\tbold,\n\tdim,\n\tcyan,\n\tyellow,\n\tmagenta,\n\tred,\n\tgreen,\n\tblue,\n} from 'yoctocolors';\nimport yoctoSpinner from './index.js';\n\nconsole.log(bold('\\n🦄 Unicorn Console Integration Demo\\n'));\nconsole.log(dim('This example shows how yocto-spinner handles console.error/warn'));\nconsole.log(dim('while the spinner is running. These write to stderr where the hook runs.\\n'));\n\nconst collectUnicorns = yoctoSpinner({\n\ttext: 'Searching for unicorns in the enchanted forest...',\n\tcolor: 'magenta',\n}).start();\n\nsetTimeout(() => {\n\tconsole.error(cyan('✨ Found a baby unicorn near the crystal stream!'));\n}, 500);\n\nsetTimeout(() => {\n\tconsole.error(yellow('✨ Spotted a golden unicorn on the rainbow bridge!'));\n}, 1000);\n\nsetTimeout(() => {\n\tconsole.warn(yellow('⚠️  A wild unicorn is shy and hiding behind clouds'));\n}, 1500);\n\nsetTimeout(() => {\n\tconsole.log(blue('🧭 Tracking hoofprints deeper into the meadow'));\n}, 1800);\n\nsetTimeout(() => {\n\tconsole.error(magenta('✨ Discovered a unicorn herd in the meadow!'));\n}, 2100);\n\nsetTimeout(() => {\n\tconsole.error(red('❌ Dark forest area is too dangerous to explore'));\n}, 2600);\n\nsetTimeout(() => {\n\tcollectUnicorns.success(green('Collected 3 magical unicorns! 🦄🦄🦄'));\n\n\tconst processSpinner = yoctoSpinner({\n\t\ttext: 'Processing unicorn magic...',\n\t\tcolor: 'cyan',\n\t}).start();\n\n\tsetTimeout(() => {\n\t\tconsole.error(blue('🌟 Converting stardust to rainbow essence'));\n\t}, 500);\n\n\tsetTimeout(() => {\n\t\tconsole.error(magenta('🌈 Brewing magical unicorn potion'));\n\t}, 1000);\n\n\tsetTimeout(() => {\n\t\tconsole.error(yellow('✨ Enchanting unicorn horn fragments'));\n\t}, 1500);\n\n\tsetTimeout(() => {\n\t\tprocessSpinner.success(green('Unicorn magic processed successfully!'));\n\n\t\tconst deploySpinner = yoctoSpinner({\n\t\t\ttext: 'Deploying unicorn powers to the world...',\n\t\t\tcolor: 'magenta',\n\t\t}).start();\n\n\t\tsetTimeout(() => {\n\t\t\tconsole.error(magenta('💫 Spreading joy and sparkles'));\n\t\t}, 400);\n\n\t\tsetTimeout(() => {\n\t\t\tconsole.error(blue('🎨 Painting rainbows across the sky'));\n\t\t}, 800);\n\n\t\tsetTimeout(() => {\n\t\t\tconsole.error(yellow('⭐ Granting wishes to believers'));\n\t\t}, 1200);\n\n\t\tsetTimeout(() => {\n\t\t\tdeploySpinner.success(bold(green('🦄 Unicorn powers deployed! The world is more magical now! ✨')));\n\n\t\t\tconsole.log(dim('\\n' + '─'.repeat(60)));\n\t\t\tconsole.log(bold('\\n📊 Mission Summary:'));\n\t\t\tconsole.log('  • Unicorns collected: ' + bold('3'));\n\t\t\tconsole.log('  • Magic spells cast: ' + bold('6'));\n\t\t\tconsole.log('  • Rainbows created: ' + bold('∞'));\n\t\t\tconsole.log('  • World happiness: ' + bold(green('+1000%')));\n\t\t\tconsole.log(dim('\\n' + '─'.repeat(60)));\n\n\t\t\tconsole.log(bold('\\n✨ Notice how console.error/warn/log appeared cleanly above the spinner!'));\n\t\t\tconsole.log(dim('The spinner automatically clears, shows your message, then re-renders below.'));\n\t\t\tconsole.log(dim('Both console.log() and console.error/warn() work seamlessly while spinning!\\n'));\n\t\t}, 1600);\n\t}, 2000);\n}, 3000);\n"
  },
  {
    "path": "example-non-interactive.js",
    "content": "import process from 'node:process';\nimport yoctoSpinner from './index.js';\n\nprocess.env.CI = 'true';\n\nconst spinner = yoctoSpinner({\n\ttext: 'Attempt 1 failed. There are 100 retries left.',\n}).start();\n\nsetTimeout(() => {\n\tspinner.text = 'Attempt 2 failed. There are 99 retries left.';\n}, 1000);\n\nsetTimeout(() => {\n\tspinner.text = 'Attempt 3 failed. There are 98 retries left.';\n}, 2000);\n\nsetTimeout(() => {\n\tspinner.success('Deploy completed successfully!');\n}, 3000);\n"
  },
  {
    "path": "example.js",
    "content": "import yoctoSpinner from './index.js';\n\nconst spinner = yoctoSpinner({\n\ttext: 'Loading unicorns\\n  (And rainbows)',\n}).start();\n\nsetTimeout(() => {\n\tspinner.text = 'Calculating splines';\n}, 2000);\n\nsetTimeout(() => {\n\tspinner.success('Finished!');\n}, 5000);\n"
  },
  {
    "path": "index.d.ts",
    "content": "import {type Writable} from 'node:stream';\n\nexport type SpinnerStyle = {\n\treadonly interval?: number;\n\treadonly frames: string[];\n};\n\nexport type Color =\n\t| 'black'\n\t| 'red'\n\t| 'green'\n\t| 'yellow'\n\t| 'blue'\n\t| 'magenta'\n\t| 'cyan'\n\t| 'white'\n\t| 'gray';\n\nexport type Options = {\n\t/**\n\tText to display next to the spinner.\n\n\t@default ''\n\t*/\n\treadonly text?: string;\n\n\t/**\n\tCustomize the spinner animation with a custom set of frames and interval.\n\n\t```\n\t{\n\t\tframes: ['-', '\\\\', '|', '/'],\n\t\tinterval: 100,\n\t}\n\t```\n\n\tPass in any spinner from [`cli-spinners`](https://github.com/sindresorhus/cli-spinners).\n\t*/\n\treadonly spinner?: SpinnerStyle;\n\n\t/**\n\tThe color of the spinner.\n\n\t@default 'cyan'\n\t*/\n\treadonly color?: Color;\n\n\t/**\n\tThe stream to which the spinner is written.\n\n\t@default process.stderr\n\t*/\n\treadonly stream?: Writable;\n};\n\nexport type Spinner = {\n\t/**\n\tChange the text displayed next to the spinner.\n\n\t@example\n\t```\n\tspinner.text = 'New text';\n\t```\n\t*/\n\ttext: string;\n\n\t/**\n\tChange the spinner color.\n\t*/\n\tcolor: Color;\n\n\t/**\n\tStarts the spinner.\n\n\tOptionally, updates the text.\n\n\t@param text - The text to display next to the spinner.\n\t@returns The spinner instance.\n\t*/\n\tstart(text?: string): Spinner;\n\n\t/**\n\tStops the spinner.\n\n\tOptionally displays a final message.\n\n\t@param finalText - The final text to display after stopping the spinner.\n\t@returns The spinner instance.\n\t*/\n\tstop(finalText?: string): Spinner;\n\n\t/**\n\tStops the spinner and displays a success symbol with the message.\n\n\t@param text - The success message to display.\n\t@returns The spinner instance.\n\t*/\n\tsuccess(text?: string): Spinner;\n\n\t/**\n\tStops the spinner and displays an error symbol with the message.\n\n\t@param text - The error message to display.\n\t@returns The spinner instance.\n\t*/\n\terror(text?: string): Spinner;\n\n\t/**\n\tStops the spinner and displays a warning symbol with the message.\n\n\t@param text - The warning message to display.\n\t@returns The spinner instance.\n\t*/\n\twarning(text?: string): Spinner;\n\n\t/**\n\tStops the spinner and displays an info symbol with the message.\n\n\t@param text - The info message to display.\n\t@returns The spinner instance.\n\t*/\n\tinfo(text?: string): Spinner;\n\n\t/**\n\tClears the spinner.\n\n\t@returns The spinner instance.\n\t*/\n\tclear(): Spinner;\n\n\t/**\n\tReturns whether the spinner is currently spinning.\n\t*/\n\tget isSpinning(): boolean;\n};\n\n/**\nCreates a new spinner instance.\n\n@returns A new spinner instance.\n\n@example\n```\nimport yoctoSpinner from 'yocto-spinner';\n\nconst spinner = yoctoSpinner({text: 'Loading…'}).start();\n\nsetTimeout(() => {\n\tspinner.success('Success!');\n}, 2000);\n```\n*/\nexport default function yoctoSpinner(options?: Options): Spinner;\n"
  },
  {
    "path": "index.js",
    "content": "import process from 'node:process';\nimport {stripVTControlCharacters} from 'node:util';\nimport yoctocolors from 'yoctocolors';\n\nconst isUnicodeSupported = process.platform !== 'win32'\n\t|| Boolean(process.env.WT_SESSION) // Windows Terminal\n\t|| process.env.TERM_PROGRAM === 'vscode';\n\nconst isInteractive = stream => Boolean(\n\tstream.isTTY\n\t&& process.env.TERM !== 'dumb'\n\t&& !('CI' in process.env),\n);\n\nconst infoSymbol = yoctocolors.blue(isUnicodeSupported ? 'ℹ' : 'i');\nconst successSymbol = yoctocolors.green(isUnicodeSupported ? '✔' : '√');\nconst warningSymbol = yoctocolors.yellow(isUnicodeSupported ? '⚠' : '‼');\nconst errorSymbol = yoctocolors.red(isUnicodeSupported ? '✖' : '×');\n\nconst defaultSpinner = {\n\tframes: isUnicodeSupported\n\t\t? [\n\t\t\t'⠋',\n\t\t\t'⠙',\n\t\t\t'⠹',\n\t\t\t'⠸',\n\t\t\t'⠼',\n\t\t\t'⠴',\n\t\t\t'⠦',\n\t\t\t'⠧',\n\t\t\t'⠇',\n\t\t\t'⠏',\n\t\t]\n\t\t: [\n\t\t\t'-',\n\t\t\t'\\\\',\n\t\t\t'|',\n\t\t\t'/',\n\t\t],\n\tinterval: 80,\n};\n\nconst SYNCHRONIZED_OUTPUT_ENABLE = '\\u001B[?2026h';\nconst SYNCHRONIZED_OUTPUT_DISABLE = '\\u001B[?2026l';\n\nconst activeHooksPerStream = new Set();\n\nclass YoctoSpinner {\n\t#frames;\n\t#interval;\n\t#currentFrame = -1;\n\t#timer;\n\t#text;\n\t#stream;\n\t#color;\n\t#lines = 0;\n\t#exitHandlerBound;\n\t#isInteractive;\n\t#lastSpinnerFrameTime = 0;\n\t#isSpinning = false;\n\t#hookedStreams = new Map();\n\t#isInternalWrite = false;\n\t#isDeferringRender = false;\n\n\tconstructor(options = {}) {\n\t\tconst spinner = options.spinner ?? defaultSpinner;\n\n\t\tif (!Array.isArray(spinner.frames) || spinner.frames.length === 0 || spinner.frames.some(frame => typeof frame !== 'string')) {\n\t\t\tthrow new Error('The `spinner.frames` option must be a non-empty array of strings');\n\t\t}\n\n\t\tif (spinner.interval !== undefined && !(Number.isInteger(spinner.interval) && spinner.interval > 0)) {\n\t\t\tthrow new Error('The `spinner.interval` option must be a positive integer');\n\t\t}\n\n\t\tthis.#frames = spinner.frames;\n\t\tthis.#interval = spinner.interval ?? defaultSpinner.interval;\n\t\tthis.#text = options.text ?? '';\n\t\tthis.#stream = options.stream ?? process.stderr;\n\t\tthis.#color = options.color ?? 'cyan';\n\t\tthis.#isInteractive = isInteractive(this.#stream);\n\t\tthis.#exitHandlerBound = this.#exitHandler.bind(this);\n\t}\n\n\t#internalWrite(action) {\n\t\tthis.#isInternalWrite = true;\n\t\ttry {\n\t\t\treturn action();\n\t\t} finally {\n\t\t\tthis.#isInternalWrite = false;\n\t\t}\n\t}\n\n\t#stringifyChunk(chunk, encoding) {\n\t\tif (chunk === undefined || chunk === null) {\n\t\t\treturn '';\n\t\t}\n\n\t\tif (typeof chunk === 'string') {\n\t\t\treturn chunk;\n\t\t}\n\n\t\tif (Buffer.isBuffer(chunk) || ArrayBuffer.isView(chunk)) {\n\t\t\tconst normalizedEncoding = typeof encoding === 'string' && encoding !== '' && encoding !== 'buffer' ? encoding : 'utf8';\n\t\t\treturn Buffer.from(chunk).toString(normalizedEncoding);\n\t\t}\n\n\t\treturn String(chunk);\n\t}\n\n\t#withSynchronizedOutput(action) {\n\t\tif (!this.#isInteractive) {\n\t\t\treturn action();\n\t\t}\n\n\t\ttry {\n\t\t\tthis.#write(SYNCHRONIZED_OUTPUT_ENABLE);\n\t\t\treturn action();\n\t\t} finally {\n\t\t\tthis.#write(SYNCHRONIZED_OUTPUT_DISABLE);\n\t\t}\n\t}\n\n\t#hookStream(stream) {\n\t\tif (!stream || this.#hookedStreams.has(stream) || typeof stream.write !== 'function') {\n\t\t\treturn;\n\t\t}\n\n\t\tif (activeHooksPerStream.has(stream)) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst originalWrite = stream.write;\n\t\tconst hookedWrite = (...writeArguments) => this.#hookedWrite(stream, originalWrite, writeArguments);\n\t\tthis.#hookedStreams.set(stream, {originalWrite, hookedWrite});\n\t\tactiveHooksPerStream.add(stream);\n\t\tstream.write = hookedWrite;\n\t}\n\n\t#installHook() {\n\t\tif (!this.#isInteractive || this.#hookedStreams.size > 0) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst streamsToHook = new Set([this.#stream]);\n\n\t\tif (this.#stream === process.stdout || this.#stream === process.stderr) {\n\t\t\tif (isInteractive(process.stdout)) {\n\t\t\t\tstreamsToHook.add(process.stdout);\n\t\t\t}\n\n\t\t\tif (isInteractive(process.stderr)) {\n\t\t\t\tstreamsToHook.add(process.stderr);\n\t\t\t}\n\t\t}\n\n\t\tfor (const stream of streamsToHook) {\n\t\t\tthis.#hookStream(stream);\n\t\t}\n\t}\n\n\t#uninstallHook() {\n\t\tfor (const [stream, hookInfo] of this.#hookedStreams) {\n\t\t\tif (stream.write === hookInfo.hookedWrite) {\n\t\t\t\tstream.write = hookInfo.originalWrite;\n\t\t\t}\n\n\t\t\tactiveHooksPerStream.delete(stream);\n\t\t}\n\n\t\tthis.#hookedStreams.clear();\n\t}\n\n\t#hookedWrite(stream, originalWrite, writeArguments) {\n\t\tconst [chunk, encoding, callback] = writeArguments;\n\t\tlet resolvedEncoding = encoding;\n\t\tlet resolvedCallback = callback;\n\n\t\tif (typeof resolvedEncoding === 'function') {\n\t\t\tresolvedCallback = resolvedEncoding;\n\t\t\tresolvedEncoding = undefined;\n\t\t}\n\n\t\tif (this.#isInternalWrite || !this.isSpinning) {\n\t\t\treturn originalWrite.call(stream, chunk, resolvedEncoding, resolvedCallback);\n\t\t}\n\n\t\tif (this.#lines > 0) {\n\t\t\tthis.clear();\n\t\t}\n\n\t\tconst chunkString = this.#stringifyChunk(chunk, resolvedEncoding);\n\t\tconst chunkTerminatesLine = chunkString.at(-1) === '\\n';\n\t\tconst writeResult = originalWrite.call(stream, chunk, resolvedEncoding, resolvedCallback);\n\n\t\tif (chunkTerminatesLine) {\n\t\t\tthis.#isDeferringRender = false;\n\t\t} else if (chunkString !== '') {\n\t\t\tthis.#isDeferringRender = true;\n\t\t}\n\n\t\tif (this.isSpinning && !this.#isDeferringRender) {\n\t\t\tthis.#render();\n\t\t}\n\n\t\treturn writeResult;\n\t}\n\n\tstart(text) {\n\t\tif (text) {\n\t\t\tthis.#text = text;\n\t\t}\n\n\t\tif (this.isSpinning) {\n\t\t\treturn this;\n\t\t}\n\n\t\tthis.#isSpinning = true;\n\t\tthis.#hideCursor();\n\t\tthis.#installHook();\n\t\tthis.#render();\n\t\tthis.#subscribeToProcessEvents();\n\n\t\t// Only start the timer in interactive mode\n\t\tif (this.#isInteractive) {\n\t\t\tthis.#timer = setInterval(() => {\n\t\t\t\tthis.#render();\n\t\t\t}, this.#interval);\n\t\t}\n\n\t\treturn this;\n\t}\n\n\tstop(finalText) {\n\t\tif (!this.isSpinning) {\n\t\t\treturn this;\n\t\t}\n\n\t\tconst shouldWriteNewline = this.#isDeferringRender;\n\t\tthis.#isSpinning = false;\n\t\tif (this.#timer) {\n\t\t\tclearInterval(this.#timer);\n\t\t\tthis.#timer = undefined;\n\t\t}\n\n\t\tthis.#isDeferringRender = false;\n\t\tthis.#uninstallHook();\n\t\tthis.#showCursor();\n\t\tthis.clear();\n\t\tthis.#unsubscribeFromProcessEvents();\n\n\t\tif (finalText) {\n\t\t\tconst prefix = shouldWriteNewline ? '\\n' : '';\n\t\t\tthis.#stream.write(`${prefix}${finalText}\\n`);\n\t\t}\n\n\t\treturn this;\n\t}\n\n\t#symbolStop(symbol, text) {\n\t\treturn this.stop(`${symbol} ${text ?? this.#text}`);\n\t}\n\n\tsuccess(text) {\n\t\treturn this.#symbolStop(successSymbol, text);\n\t}\n\n\terror(text) {\n\t\treturn this.#symbolStop(errorSymbol, text);\n\t}\n\n\twarning(text) {\n\t\treturn this.#symbolStop(warningSymbol, text);\n\t}\n\n\tinfo(text) {\n\t\treturn this.#symbolStop(infoSymbol, text);\n\t}\n\n\tget isSpinning() {\n\t\treturn this.#isSpinning;\n\t}\n\n\tget text() {\n\t\treturn this.#text;\n\t}\n\n\tset text(value) {\n\t\tthis.#text = value ?? '';\n\t\tthis.#render();\n\t}\n\n\tget color() {\n\t\treturn this.#color;\n\t}\n\n\tset color(value) {\n\t\tthis.#color = value;\n\t\tthis.#render();\n\t}\n\n\tclear() {\n\t\tif (!this.#isInteractive) {\n\t\t\treturn this;\n\t\t}\n\n\t\tif (this.#lines === 0) {\n\t\t\treturn this;\n\t\t}\n\n\t\tthis.#internalWrite(() => {\n\t\t\tthis.#stream.cursorTo(0);\n\n\t\t\tfor (let index = 0; index < this.#lines; index++) {\n\t\t\t\tif (index > 0) {\n\t\t\t\t\tthis.#stream.moveCursor(0, -1);\n\t\t\t\t}\n\n\t\t\t\tthis.#stream.clearLine(1);\n\t\t\t}\n\t\t});\n\n\t\tthis.#lines = 0;\n\n\t\treturn this;\n\t}\n\n\t#render() {\n\t\tif (this.#isDeferringRender) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst useSynchronizedOutput = this.#isInteractive;\n\t\t// Ensure we only update the spinner frame at the wanted interval,\n\t\t// even if the frame method is called more often.\n\t\tconst now = Date.now();\n\t\tif (this.#currentFrame === -1 || now - this.#lastSpinnerFrameTime >= this.#interval) {\n\t\t\tthis.#currentFrame = ++this.#currentFrame % this.#frames.length;\n\t\t\tthis.#lastSpinnerFrameTime = now;\n\t\t}\n\n\t\tconst applyColor = yoctocolors[this.#color] ?? yoctocolors.cyan;\n\t\tconst frame = this.#frames[this.#currentFrame];\n\t\tlet string = `${applyColor(frame)} ${this.#text}`;\n\n\t\tif (!this.#isInteractive) {\n\t\t\tstring += '\\n';\n\t\t}\n\n\t\tif (useSynchronizedOutput) {\n\t\t\tthis.#withSynchronizedOutput(() => {\n\t\t\t\tthis.clear();\n\t\t\t\tthis.#write(string);\n\t\t\t});\n\t\t} else {\n\t\t\tthis.#write(string);\n\t\t}\n\n\t\tif (this.#isInteractive) {\n\t\t\tthis.#lines = this.#lineCount(string);\n\t\t}\n\t}\n\n\t#write(text) {\n\t\tthis.#internalWrite(() => {\n\t\t\tthis.#stream.write(text);\n\t\t});\n\t}\n\n\t#lineCount(text) {\n\t\tconst width = this.#stream.columns ?? 80;\n\t\tconst lines = stripVTControlCharacters(text).split('\\n');\n\n\t\tlet lineCount = 0;\n\t\tfor (const line of lines) {\n\t\t\tlineCount += Math.max(1, Math.ceil(line.length / width));\n\t\t}\n\n\t\treturn lineCount;\n\t}\n\n\t#hideCursor() {\n\t\tif (this.#isInteractive) {\n\t\t\tthis.#write('\\u001B[?25l');\n\t\t}\n\t}\n\n\t#showCursor() {\n\t\tif (this.#isInteractive) {\n\t\t\tthis.#write('\\u001B[?25h');\n\t\t}\n\t}\n\n\t#subscribeToProcessEvents() {\n\t\tprocess.once('SIGINT', this.#exitHandlerBound);\n\t\tprocess.once('SIGTERM', this.#exitHandlerBound);\n\t}\n\n\t#unsubscribeFromProcessEvents() {\n\t\tprocess.off('SIGINT', this.#exitHandlerBound);\n\t\tprocess.off('SIGTERM', this.#exitHandlerBound);\n\t}\n\n\t#exitHandler(signal) {\n\t\tif (this.isSpinning) {\n\t\t\tthis.stop();\n\t\t}\n\n\t\t// SIGINT: 128 + 2\n\t\t// SIGTERM: 128 + 15\n\t\tconst exitCode = signal === 'SIGINT' ? 130 : (signal === 'SIGTERM' ? 143 : 1);\n\t\tprocess.exit(exitCode);\n\t}\n}\n\nexport default function yoctoSpinner(options) {\n\treturn new YoctoSpinner(options);\n}\n"
  },
  {
    "path": "license",
    "content": "MIT License\n\nCopyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"yocto-spinner\",\n\t\"version\": \"1.1.0\",\n\t\"description\": \"Tiny terminal spinner\",\n\t\"license\": \"MIT\",\n\t\"repository\": \"sindresorhus/yocto-spinner\",\n\t\"funding\": \"https://github.com/sponsors/sindresorhus\",\n\t\"author\": {\n\t\t\"name\": \"Sindre Sorhus\",\n\t\t\"email\": \"sindresorhus@gmail.com\",\n\t\t\"url\": \"https://sindresorhus.com\"\n\t},\n\t\"type\": \"module\",\n\t\"exports\": {\n\t\t\"types\": \"./index.d.ts\",\n\t\t\"default\": \"./index.js\"\n\t},\n\t\"sideEffects\": false,\n\t\"engines\": {\n\t\t\"node\": \">=18.19\"\n\t},\n\t\"scripts\": {\n\t\t\"test\": \"xo && ava && tsc index.d.ts\"\n\t},\n\t\"files\": [\n\t\t\"index.js\",\n\t\t\"index.d.ts\"\n\t],\n\t\"keywords\": [\n\t\t\"cli\",\n\t\t\"spinner\",\n\t\t\"spinners\",\n\t\t\"terminal\",\n\t\t\"term\",\n\t\t\"console\",\n\t\t\"ascii\",\n\t\t\"unicode\",\n\t\t\"loading\",\n\t\t\"indicator\",\n\t\t\"progress\",\n\t\t\"busy\",\n\t\t\"wait\",\n\t\t\"idle\",\n\t\t\"tiny\",\n\t\t\"yocto\",\n\t\t\"micro\",\n\t\t\"nano\"\n\t],\n\t\"dependencies\": {\n\t\t\"yoctocolors\": \"^2.1.1\"\n\t},\n\t\"devDependencies\": {\n\t\t\"ava\": \"^6.1.3\",\n\t\t\"get-stream\": \"^9.0.1\",\n\t\t\"strip-ansi\": \"^7.1.2\",\n\t\t\"typescript\": \"^5.5.4\",\n\t\t\"xo\": \"^0.59.3\"\n\t},\n\t\"xo\": {\n\t\t\"rules\": {\n\t\t\t\"unicorn/no-process-exit\": \"off\"\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "readme.md",
    "content": "<h1 align=\"center\" title=\"yocto-spinner\">\n\t<img src=\"media/logo.jpg\" alt=\"yocto-spinner logo\">\n</h1>\n\n[![Install size](https://packagephobia.com/badge?p=yocto-spinner)](https://packagephobia.com/result?p=yocto-spinner)\n![npm package minzipped size](https://img.shields.io/bundlejs/size/yocto-spinner)\n<!-- [![Downloads](https://img.shields.io/npm/dm/yocto-spinner.svg)](https://npmjs.com/yocto-spinner) -->\n<!-- ![Dependents](https://img.shields.io/librariesio/dependents/npm/yocto-spinner) -->\n\n> Tiny terminal spinner\n\n## Features\n\n- Tiny and fast\n- Customizable text and color options\n- Customizable spinner animations\n- Only one tiny dependency\n- Supports both Unicode and non-Unicode environments\n- Gracefully handles process signals (e.g., `SIGINT`, `SIGTERM`)\n- Can display different status symbols (info, success, warning, error)\n- Works well in CI environments\n\n*Check out [`ora`](https://github.com/sindresorhus/ora) for more features.*\n\n<br>\n<p align=\"center\">\n\t<br>\n\t<img src=\"https://raw.githubusercontent.com/sindresorhus/ora/3c63d5e8569d94564b5280525350724817e9ac26/screenshot.svg\" width=\"500\">\n\t<br>\n</p>\n<br>\n\n## Install\n\n```sh\nnpm install yocto-spinner\n```\n\n## Usage\n\n```js\nimport yoctoSpinner from 'yocto-spinner';\n\nconst spinner = yoctoSpinner({text: 'Loading…'}).start();\n\nsetTimeout(() => {\n\tspinner.success('Success!');\n}, 2000);\n```\n\n## API\n\n### yoctoSpinner(options?)\n\nCreates a new spinner instance.\n\n#### options\n\nType: `object`\n\n##### text\n\nType: `string`\\\nDefault: `''`\n\nThe text to display next to the spinner.\n\n##### spinner\n\nType: `object`\\\nDefault: <img src=\"https://github.com/sindresorhus/ora/blob/main/screenshot-spinner.gif?raw=true\" width=\"14\">\n\nCustomize the spinner animation with a custom set of frames and interval.\n\n```js\n{\n\tframes: ['-', '\\\\', '|', '/'],\n\tinterval: 100,\n}\n```\n\nPass in any spinner from [`cli-spinners`](https://github.com/sindresorhus/cli-spinners).\n\n##### color\n\nType: `string`\\\nDefault: `'cyan'`\\\nValues: `'black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white' | 'gray'`\n\nThe color of the spinner.\n\n##### stream\n\nType: `stream.Writable`\\\nDefault: `process.stderr`\n\nThe stream to which the spinner is written.\n\n### Instance methods\n\n#### .start(text?)\n\nStarts the spinner.\n\nReturns the instance.\n\nOptionally, updates the text:\n\n```js\nspinner.start('Loading…');\n```\n\n#### .stop(finalText?)\n\nStops the spinner.\n\nReturns the instance.\n\nOptionally displays a final message.\n\n```js\nspinner.stop('Stopped.');\n```\n\n#### .success(text?)\n\nStops the spinner and displays a success symbol with the message.\n\nReturns the instance.\n\n```js\nspinner.success('Success!');\n```\n\n#### .error(text?)\n\nStops the spinner and displays an error symbol with the message.\n\nReturns the instance.\n\n```js\nspinner.error('Error!');\n```\n\n#### .warning(text?)\n\nStops the spinner and displays a warning symbol with the message.\n\nReturns the instance.\n\n```js\nspinner.warning('Warning!');\n```\n\n#### .clear()\n\nClears the spinner.\n\nReturns the instance.\n\n#### .info(text?)\n\nStops the spinner and displays an info symbol with the message.\n\nReturns the instance.\n\n```js\nspinner.info('Info.');\n```\n\n#### .text <sup>get/set</sup>\n\nChange the text displayed next to the spinner.\n\n```js\nspinner.text = 'New text';\n```\n\n#### .color <sup>get/set</sup>\n\nChange the spinner color.\n\n#### .isSpinning <sup>get</sup>\n\nReturns whether the spinner is currently spinning.\n\n## FAQ\n\n### How do I change the color of the text?\n\nUse [`yoctocolors`](https://github.com/sindresorhus/yoctocolors):\n\n```js\nimport yoctoSpinner from 'yocto-spinner';\nimport {red} from 'yoctocolors';\n\nconst spinner = yoctoSpinner({text: `Loading ${red('unicorns')}`}).start();\n```\n\n### Can I log messages while the spinner is running?\n\nYes. The spinner clears itself, writes your message, and re-renders below. This works with `console.log()` and `console.error()` while the spinner is running:\n\n```js\nconst spinner = yoctoSpinner({text: 'Processing…'}).start();\n\nconsole.log('Step 1 complete');\nconsole.error('Step 2 complete');\n\nspinner.success('Done!');\n```\n\n> [!NOTE]\n> Avoid running multiple spinners concurrently.\n\n### Why does the spinner freeze?\n\nJavaScript is single-threaded, so any synchronous operations will block the spinner's animation. To avoid this, prefer using asynchronous operations.\n\n## Comparison with [`ora`](https://github.com/sindresorhus/ora)\n\nOra offers more options, greater customizability, [promise handling](https://github.com/sindresorhus/ora?tab=readme-ov-file#orapromiseaction-options), and better Unicode detection. It’s a more mature and feature-rich package that handles more edge cases but comes with additional dependencies and a larger size. In contrast, this package is smaller, simpler, and optimized for minimal overhead, making it ideal for lightweight projects where dependency size is important. However, Ora is generally the better choice for most use cases.\n\n## Related\n\n- [ora](https://github.com/sindresorhus/ora) - Comprehensive terminal spinner\n- [yoctocolors](https://github.com/sindresorhus/yoctocolors) - Tiny terminal coloring\n- [nano-spawn](https://github.com/sindresorhus/nano-spawn) - Tiny process execution for humans\n"
  },
  {
    "path": "test.js",
    "content": "import {setTimeout as delay} from 'node:timers/promises';\nimport process from 'node:process';\nimport {PassThrough} from 'node:stream';\nimport getStream from 'get-stream';\nimport test from 'ava';\nimport stripAnsi from 'strip-ansi';\nimport yoctocolors from 'yoctocolors';\nimport yoctoSpinner from './index.js';\n\ndelete process.env.CI;\n\nconst synchronizedOutputEnable = '\\u001B[?2026h';\nconst synchronizedOutputDisable = '\\u001B[?2026l';\n\nconst getPassThroughStream = () => {\n\tconst stream = new PassThrough();\n\tstream.clearLine = () => {};\n\tstream.cursorTo = () => {};\n\tstream.moveCursor = () => {};\n\treturn stream;\n};\n\nconst runSpinner = async (function_, options = {}, testOptions = {}) => {\n\tconst stream = testOptions.stream ?? getPassThroughStream();\n\t// Set isTTY to false by default for tests to get predictable newline behavior\n\tif (stream.isTTY === undefined) {\n\t\tstream.isTTY = false;\n\t}\n\n\tconst output = getStream(stream);\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: testOptions.text ?? 'foo',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 10_000,\n\t\t},\n\t\t...options,\n\t});\n\n\tspinner.start();\n\tfunction_(spinner);\n\tstream.end();\n\n\treturn stripAnsi(await output);\n};\n\ntest('start and stop spinner', async t => {\n\tconst output = await runSpinner(spinner => spinner.stop());\n\tt.is(output, '- foo\\n');\n});\n\ntest('spinner.success()', async t => {\n\tconst output = await runSpinner(spinner => spinner.success());\n\tt.regex(output, /✔ foo\\n$/);\n});\n\ntest('spinner.error()', async t => {\n\tconst output = await runSpinner(spinner => spinner.error());\n\tt.regex(output, /✖ foo\\n$/);\n});\n\ntest('spinner.warning()', async t => {\n\tconst output = await runSpinner(spinner => spinner.warning());\n\tt.regex(output, /⚠ foo\\n$/);\n});\n\ntest('spinner.info()', async t => {\n\tconst output = await runSpinner(spinner => spinner.info());\n\tt.regex(output, /ℹ foo\\n$/);\n});\n\ntest('spinner changes text', async t => {\n\tconst output = await runSpinner(spinner => {\n\t\tspinner.text = 'bar';\n\t\tspinner.stop();\n\t});\n\tt.is(output, '- foo\\n- bar\\n');\n});\n\ntest('spinner stops with final text', async t => {\n\tconst output = await runSpinner(spinner => spinner.stop('final'));\n\tt.regex(output, /final\\n$/);\n});\n\ntest('spinner with non-TTY stream', t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = false;\n\tconst spinner = yoctoSpinner({stream, text: 'foo'});\n\n\tspinner.start();\n\tspinner.stop('final');\n\tt.pass();\n});\n\ntest('spinner does not hook non-interactive streams', t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = false;\n\n\tconst spinner = yoctoSpinner({stream, text: 'foo'});\n\tconst originalWrite = stream.write;\n\n\tspinner.start();\n\tt.is(stream.write, originalWrite);\n\tspinner.stop();\n\tt.is(stream.write, originalWrite);\n});\n\ntest('spinner starts with custom text', async t => {\n\tconst output = await runSpinner(spinner => spinner.stop(), {text: 'custom'});\n\tt.is(output, '- custom\\n');\n});\n\ntest('spinner uses synchronized output in interactive mode', async t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = true;\n\n\tconst output = getStream(stream);\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'foo',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 10_000,\n\t\t},\n\t});\n\n\tspinner.start();\n\tspinner.stop();\n\tstream.end();\n\n\tconst result = await output;\n\tt.true(result.includes(synchronizedOutputEnable));\n\tt.true(result.includes(synchronizedOutputDisable));\n\tt.true(result.indexOf(synchronizedOutputEnable) < result.indexOf(synchronizedOutputDisable));\n});\n\ntest('spinner starts and changes text multiple times', async t => {\n\tconst output = await runSpinner(spinner => {\n\t\tspinner.text = 'bar';\n\t\tspinner.text = 'baz';\n\t\tspinner.stop();\n\t});\n\tt.is(output, '- foo\\n- bar\\n- baz\\n');\n});\n\ntest('spinner handles multiple start/stop cycles', async t => {\n\tconst output = await runSpinner(spinner => {\n\t\tspinner.stop();\n\t\tspinner.start('bar');\n\t\tspinner.stop();\n\t\tspinner.start('baz');\n\t\tspinner.stop();\n\t});\n\tt.is(output, '- foo\\n- bar\\n- baz\\n');\n});\n\ntest('spinner stops with success symbol and final text', async t => {\n\tconst output = await runSpinner(spinner => spinner.success('done'));\n\tt.regex(output, /✔ done\\n$/);\n});\n\ntest('spinner stops with error symbol and final text', async t => {\n\tconst output = await runSpinner(spinner => spinner.error('failed'));\n\tt.regex(output, /✖ failed\\n$/);\n});\n\ntest('spinner accounts for ANSI escape codes when computing line breaks', async t => {\n\tconst scenarios = [\n\t\t// 1 symbol + 1 space + 78 chars = 80 chars, max for one line\n\t\t{\n\t\t\ttextLength: 78,\n\t\t\tclearLineCount: 1,\n\t\t},\n\n\t\t// 1 symbol + 1 space + 79 chars = 81 chars, split on two lines\n\t\t{\n\t\t\ttextLength: 79,\n\t\t\tclearLineCount: 2,\n\t\t},\n\t];\n\n\tfor (const scenario of scenarios) {\n\t\tlet clearLineCount = 0;\n\n\t\tconst stream = new PassThrough();\n\t\tstream.clearLine = () => {\n\t\t\tclearLineCount += 1;\n\t\t};\n\n\t\tstream.cursorTo = () => {};\n\t\tstream.moveCursor = () => {};\n\t\tstream.isTTY = true;\n\n\t\tlet text = '';\n\t\tfor (let i = 0; i < scenario.textLength; i++) {\n\t\t\ttext += yoctocolors.blue('a');\n\t\t}\n\n\t\t// eslint-disable-next-line no-await-in-loop\n\t\tawait runSpinner(spinner => spinner.stop(), {}, {\n\t\t\tstream,\n\t\t\ttext,\n\t\t});\n\t\tt.is(clearLineCount, scenario.clearLineCount);\n\t}\n});\n\ntest('spinner in non-interactive mode only renders on text changes', async t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = false;\n\n\tconst output = getStream(stream);\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'initial text',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 10,\n\t\t},\n\t});\n\n\tspinner.start();\n\n\t// Wait to ensure no additional renders happen\n\tawait delay(50);\n\n\tspinner.text = 'changed text';\n\n\tawait delay(50);\n\n\tspinner.stop('final text');\n\tstream.end();\n\n\tconst result = stripAnsi(await output);\n\tconst lines = result.trim().split('\\n');\n\n\t// Should only have 3 lines: initial, changed, and final\n\tt.is(lines.length, 3);\n\tt.is(lines[0], '- initial text');\n\tt.is(lines[1], '- changed text');\n\tt.is(lines[2], 'final text');\n});\n\ntest('spinner keeps output below external writes while spinning', t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = true;\n\n\tconst cursorToCalls = [];\n\tconst writeEvents = [];\n\n\tconst originalWrite = stream.write;\n\tstream.write = function (content, encoding, callback) {\n\t\twriteEvents.push(stripAnsi(String(content)));\n\t\treturn originalWrite.call(this, content, encoding, callback);\n\t};\n\n\tstream.cursorTo = () => {\n\t\tcursorToCalls.push('cursorTo');\n\t};\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'spinning',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 10_000,\n\t\t},\n\t});\n\n\tspinner.start();\n\n\tconst cursorToCountAfterStart = cursorToCalls.length;\n\n\tstream.write('External log\\n');\n\n\tspinner.stop('done');\n\tstream.end();\n\n\tt.true(cursorToCalls.length > cursorToCountAfterStart, 'external write should clear the spinner before output');\n\n\tconst externalWriteIndex = writeEvents.findIndex(event => event.includes('External log'));\n\tt.true(externalWriteIndex !== -1);\n\n\tconst reRenderIndex = writeEvents.findIndex((event, index) => index > externalWriteIndex && event.includes('spinning'));\n\tt.true(reRenderIndex !== -1, 'spinner should re-render after external write');\n});\n\ntest('external writes preserve chunk boundaries without injected newlines', async t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = true;\n\tconst outputPromise = getStream(stream);\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'processing',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 10_000,\n\t\t},\n\t});\n\n\tspinner.start();\n\n\tstream.write('Downloading ');\n\tstream.write('42%');\n\tstream.write('\\n');\n\n\tspinner.stop();\n\tstream.end();\n\n\tconst output = stripAnsi(await outputPromise).replaceAll('\\r', '');\n\tt.true(output.includes('Downloading 42%\\n'));\n\tt.false(output.includes('Downloading \\n42%'));\n});\n\ntest('spinner defers renders until a newline completes the external line', async t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = true;\n\n\tconst writeEvents = [];\n\n\tconst originalWrite = stream.write;\n\tstream.write = function (content, encoding, callback) {\n\t\twriteEvents.push(stripAnsi(String(content)));\n\t\treturn originalWrite.call(this, content, encoding, callback);\n\t};\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'waiting',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 20,\n\t\t},\n\t});\n\n\tspinner.start();\n\tconst baselineWrites = writeEvents.length;\n\n\tstream.write('Partial without newline');\n\tawait delay(80);\n\n\tt.is(writeEvents.length, baselineWrites + 1, 'spinner should not render while line is incomplete');\n\n\tstream.write('\\n');\n\tawait delay(40);\n\n\tt.true(writeEvents.length > baselineWrites + 1, 'spinner should render after newline');\n\n\tspinner.stop();\n\tstream.end();\n});\n\ntest('spinner defers renders on carriage return updates', async t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = true;\n\n\tconst writeEvents = [];\n\n\tconst originalWrite = stream.write;\n\tstream.write = function (content, encoding, callback) {\n\t\twriteEvents.push(stripAnsi(String(content)));\n\t\treturn originalWrite.call(this, content, encoding, callback);\n\t};\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'waiting',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 20,\n\t\t},\n\t});\n\n\tspinner.start();\n\tconst baselineWrites = writeEvents.length;\n\n\tstream.write('\\rProgress 1');\n\tawait delay(80);\n\n\tt.is(writeEvents.length, baselineWrites + 1, 'spinner should not render while carriage return updates are in progress');\n\n\tstream.write('\\n');\n\tawait delay(40);\n\n\tt.true(writeEvents.length > baselineWrites + 1, 'spinner should render after newline');\n\n\tspinner.stop();\n\tstream.end();\n});\n\ntest('spinner resumes after carriage return newline', async t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = true;\n\n\tconst writeEvents = [];\n\n\tconst originalWrite = stream.write;\n\tstream.write = function (content, encoding, callback) {\n\t\twriteEvents.push(stripAnsi(String(content)));\n\t\treturn originalWrite.call(this, content, encoding, callback);\n\t};\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'waiting',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 20,\n\t\t},\n\t});\n\n\tspinner.start();\n\tconst baselineWrites = writeEvents.length;\n\n\tstream.write('\\rProgress 1\\r\\n');\n\tawait delay(80);\n\n\tt.true(writeEvents.length > baselineWrites + 1, 'spinner should render after carriage return newline');\n\n\tspinner.stop();\n\tstream.end();\n});\n\ntest('spinner defers when chunk ends with an incomplete line', async t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = true;\n\n\tconst writeEvents = [];\n\n\tconst originalWrite = stream.write;\n\tstream.write = function (content, encoding, callback) {\n\t\twriteEvents.push(stripAnsi(String(content)));\n\t\treturn originalWrite.call(this, content, encoding, callback);\n\t};\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'waiting',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 20,\n\t\t},\n\t});\n\n\tspinner.start();\n\tconst baselineWrites = writeEvents.length;\n\n\tstream.write('Step 1\\nProgress 50%');\n\tawait delay(80);\n\n\tt.is(writeEvents.length, baselineWrites + 1, 'spinner should not render while last line is incomplete');\n\n\tstream.write('\\n');\n\tawait delay(40);\n\n\tt.true(writeEvents.length > baselineWrites + 1, 'spinner should render after newline');\n\n\tspinner.stop();\n\tstream.end();\n});\n\ntest('spinner stop preserves partial external lines', async t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = true;\n\tconst outputPromise = getStream(stream);\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'waiting',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 10_000,\n\t\t},\n\t});\n\n\tspinner.start();\n\n\tstream.write('Downloading ');\n\n\tspinner.stop('done');\n\tstream.end();\n\n\tconst output = stripAnsi(await outputPromise).replaceAll('\\r', '');\n\n\tt.true(output.includes('Downloading \\n'));\n\tt.regex(output, /Downloading \\n[\\s\\S]*done\\n/);\n\tt.false(output.includes('Downloading done'));\n});\n\ntest('spinner stop without final text preserves partial external lines', async t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = true;\n\tconst outputPromise = getStream(stream);\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'waiting',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 10_000,\n\t\t},\n\t});\n\n\tspinner.start();\n\n\tstream.write('Downloading ');\n\n\tspinner.stop();\n\tstream.end();\n\n\tconst output = stripAnsi(await outputPromise).replaceAll('\\r', '');\n\n\tt.true(output.includes('Downloading '));\n\tt.false(output.includes('Downloading \\n'));\n});\n\ntest('spinner does not defer when stdout is non-interactive', async t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = true;\n\n\tconst stdoutStream = getPassThroughStream();\n\tstdoutStream.isTTY = false;\n\n\tconst outputPromise = getStream(stream);\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'waiting',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 20,\n\t\t},\n\t});\n\n\tconst originalStdoutWrite = process.stdout.write;\n\tprocess.stdout.write = stdoutStream.write.bind(stdoutStream);\n\n\ttry {\n\t\tspinner.start();\n\t\tstdoutStream.write('chunk without newline');\n\t\tawait delay(80);\n\n\t\tspinner.stop('done');\n\t\tstream.end();\n\n\t\tconst output = stripAnsi(await outputPromise);\n\t\tt.true(output.includes('done\\n'));\n\t} finally {\n\t\tprocess.stdout.write = originalStdoutWrite;\n\t}\n});\n\ntest('spinner preserves external stream.write wrappers on stop', t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = true;\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'wrapper',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 10_000,\n\t\t},\n\t});\n\n\tspinner.start();\n\n\tconst originalWrite = stream.write;\n\tconst wrappedWrite = function (content, encoding, callback) {\n\t\treturn originalWrite.call(this, content, encoding, callback);\n\t};\n\n\tstream.write = wrappedWrite;\n\tspinner.stop();\n\n\tt.is(stream.write, wrappedWrite);\n\tstream.end();\n});\n\ntest('spinner.interval rejects negative values', t => {\n\tt.throws(() => {\n\t\tyoctoSpinner({spinner: {frames: ['-'], interval: -100}});\n\t}, {message: /positive integer/});\n});\n\ntest('spinner.interval rejects non-integer values', t => {\n\tt.throws(() => {\n\t\tyoctoSpinner({spinner: {frames: ['-'], interval: 1.5}});\n\t}, {message: /positive integer/});\n});\n\ntest('spinner.interval rejects zero', t => {\n\tt.throws(() => {\n\t\tyoctoSpinner({spinner: {frames: ['-'], interval: 0}});\n\t}, {message: /positive integer/});\n});\n\ntest('spinner.frames rejects empty array', t => {\n\tt.throws(() => {\n\t\tyoctoSpinner({spinner: {frames: []}});\n\t}, {message: /non-empty array of strings/});\n});\n\ntest('spinner.frames rejects non-array', t => {\n\tt.throws(() => {\n\t\tyoctoSpinner({spinner: {frames: 'not-an-array'}});\n\t}, {message: /non-empty array of strings/});\n});\n\ntest('spinner.frames rejects non-string elements', t => {\n\tt.throws(() => {\n\t\tyoctoSpinner({spinner: {frames: [123, 456]}});\n\t}, {message: /non-empty array of strings/});\n});\n\ntest('spinner.interval defaults to 80 when not provided', t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = false;\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\tspinner: {frames: ['a', 'b']},\n\t});\n\n\tspinner.start();\n\tspinner.stop();\n\tt.pass();\n});\n\ntest('spinner preserves pre-existing stream.write wrappers', t => {\n\tconst stream = getPassThroughStream();\n\tstream.isTTY = true;\n\n\tconst originalWrite = stream.write;\n\tconst wrappedWrite = function (content, encoding, callback) {\n\t\treturn originalWrite.call(this, content, encoding, callback);\n\t};\n\n\tstream.write = wrappedWrite;\n\n\tconst spinner = yoctoSpinner({\n\t\tstream,\n\t\ttext: 'wrapper',\n\t\tspinner: {\n\t\t\tframes: ['-'],\n\t\t\tinterval: 10_000,\n\t\t},\n\t});\n\n\tspinner.start();\n\tspinner.stop();\n\n\tt.is(stream.write, wrappedWrite);\n\tstream.end();\n});\n"
  }
]