diff --git a/src/lib/polling.ts b/src/lib/polling.ts index d0896c45..8857fe79 100644 --- a/src/lib/polling.ts +++ b/src/lib/polling.ts @@ -6,6 +6,7 @@ */ import { TimeoutError } from "./errors.js"; +import { isPlainOutput } from "./formatters/plain-detect.js"; import { formatProgressLine, truncateProgressMessage, @@ -82,7 +83,8 @@ export async function poll(options: PollOptions): Promise { } = options; const startTime = Date.now(); - const spinner = json ? null : startSpinner(initialMessage); + const suppress = json || isPlainOutput(); + const spinner = suppress ? null : startSpinner(initialMessage); try { while (Date.now() - startTime < timeoutMs) { @@ -105,17 +107,21 @@ export async function poll(options: PollOptions): Promise { throw new TimeoutError(timeoutMessage, timeoutHint); } finally { spinner?.stop(); - if (!json) { - process.stderr.write("\n"); + if (!suppress) { + process.stdout.write("\n"); } } } /** - * Start an animated spinner that writes progress to stderr. + * Start an animated spinner that writes progress to stdout. + * + * Uses stdout so the spinner doesn't collide with consola log messages + * on stderr. The spinner is erased before command output is written, + * and is suppressed entirely in JSON mode and when stdout is not a TTY. * * Returns a controller with `setMessage` to update the displayed text - * and `stop` to halt the animation. Writes directly to `process.stderr`. + * and `stop` to halt the animation. */ function startSpinner(initialMessage: string): { setMessage: (msg: string) => void; @@ -130,7 +136,7 @@ function startSpinner(initialMessage: string): { return; } const display = truncateProgressMessage(currentMessage); - process.stderr.write(`\r\x1b[K${formatProgressLine(display, tick)}`); + process.stdout.write(`\r\x1b[K${formatProgressLine(display, tick)}`); tick += 1; setTimeout(scheduleFrame, ANIMATION_INTERVAL_MS).unref(); }; @@ -158,15 +164,15 @@ export type WithProgressOptions = { }; /** - * Run an async operation with an animated spinner on stderr. + * Run an async operation with an animated spinner on stdout. * * The spinner uses the same braille frames as the Seer polling spinner, * giving a consistent look across all CLI commands. Progress output goes - * to stderr, so it never contaminates stdout (safe to use alongside JSON output). + * to stdout so it doesn't collide with consola log messages on stderr. * - * When `options.json` is true the spinner is suppressed entirely, matching - * the behaviour of {@link poll}. This avoids noisy ANSI escape sequences on - * stderr when agents or CI pipelines consume `--json` output. + * The spinner is suppressed when: + * - `options.json` is true (JSON mode — no ANSI noise for agents/CI) + * - stdout is not a TTY / plain output mode is active (piped output) * * The callback receives a `setMessage` function to update the displayed * message as work progresses (e.g. to show page counts during pagination). @@ -193,10 +199,10 @@ export async function withProgress( options: WithProgressOptions, fn: (setMessage: (msg: string) => void) => Promise ): Promise { - if (options.json) { - // JSON mode: skip the spinner entirely, pass a no-op setMessage + if (options.json || isPlainOutput()) { + // JSON mode or non-TTY: skip the spinner entirely, pass a no-op setMessage return fn(() => { - /* spinner suppressed in JSON mode */ + /* spinner suppressed */ }); } @@ -206,6 +212,6 @@ export async function withProgress( return await fn(spinner.setMessage); } finally { spinner.stop(); - process.stderr.write("\r\x1b[K"); + process.stdout.write("\r\x1b[K"); } } diff --git a/test/commands/cli/upgrade.test.ts b/test/commands/cli/upgrade.test.ts index 4dd1de52..1064de04 100644 --- a/test/commands/cli/upgrade.test.ts +++ b/test/commands/cli/upgrade.test.ts @@ -66,18 +66,27 @@ function createMockContext( ...overrides.env, }; - // Force plain output so formatUpgradeResult renders raw markdown - // instead of ANSI-styled output in TTY mode. + // Force rich output so the spinner (which uses isPlainOutput() to decide + // whether to render) is not suppressed in non-TTY test environments. + // The formatUpgradeResult output will contain ANSI codes, but test + // assertions use toContain() which matches through them. const origPlain = process.env.SENTRY_PLAIN_OUTPUT; - process.env.SENTRY_PLAIN_OUTPUT = "1"; + process.env.SENTRY_PLAIN_OUTPUT = "0"; // Capture consola output (routed to process.stderr) - const origWrite = process.stderr.write.bind(process.stderr); + const origStderrWrite = process.stderr.write.bind(process.stderr); process.stderr.write = ((chunk: string | Uint8Array) => { stderrChunks.push(String(chunk)); return true; }) as typeof process.stderr.write; + // Capture spinner output (routed to process.stdout) + const origStdoutWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + stdoutChunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + const stdoutWriter = { write: (s: string) => { stdoutChunks.push(s); @@ -130,7 +139,8 @@ function createMockContext( getOutput: () => stderrChunks.join("") + stdoutChunks.join(""), errors, restore: () => { - process.stderr.write = origWrite; + process.stderr.write = origStderrWrite; + process.stdout.write = origStdoutWrite; if (origPlain === undefined) { delete process.env.SENTRY_PLAIN_OUTPUT; } else { @@ -731,7 +741,7 @@ describe("sentry cli upgrade — curl full upgrade path (Bun.spawn spy)", () => await run(app, ["cli", "upgrade", "--method", "curl"], context); const combined = getOutput(); - // Spinner progress messages written to stderr + // Spinner progress messages written to stdout expect(combined).toContain("Checking for updates"); expect(combined).toContain("Downloading 99.99.99"); expect(combined).toContain("Upgraded to"); @@ -857,7 +867,7 @@ describe("sentry cli upgrade — curl full upgrade path (Bun.spawn spy)", () => const combined = getOutput(); // With --force, should NOT show "Already up to date" expect(combined).not.toContain("Already up to date"); - // Should proceed to download and succeed (spinner messages on stderr) + // Should proceed to download and succeed (spinner messages on stdout) expect(combined).toContain(`Downloading ${CLI_VERSION}`); expect(combined).toContain("Upgraded to"); expect(combined).toContain(CLI_VERSION); diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index 97d34b60..16f870ac 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -1058,16 +1058,20 @@ describe("pollAutofixState", () => { expect(fetchCount).toBe(2); }); - test("writes progress to stderr when not in JSON mode", async () => { - let stderrOutput = ""; + test("writes progress to stdout when not in JSON mode", async () => { + let stdoutOutput = ""; let fetchCount = 0; - // Spy on process.stderr.write to capture spinner output - const origWrite = process.stderr.write.bind(process.stderr); - process.stderr.write = ((chunk: string | Uint8Array) => { - stderrOutput += String(chunk); + // Force rich output so the spinner isn't suppressed in non-TTY test env + const origPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + + // Spy on process.stdout.write to capture spinner output + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + stdoutOutput += String(chunk); return true; - }) as typeof process.stderr.write; + }) as typeof process.stdout.write; try { // Return PROCESSING first to allow animation interval to fire, @@ -1127,9 +1131,14 @@ describe("pollAutofixState", () => { pollIntervalMs: 100, // Allow animation interval (80ms) to fire }); - expect(stderrOutput).toContain("Analyzing"); + expect(stdoutOutput).toContain("Analyzing"); } finally { - process.stderr.write = origWrite; + process.stdout.write = origWrite; + if (origPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = origPlain; + } } }); @@ -1505,16 +1514,20 @@ describe("ensureRootCauseAnalysis", () => { expect(triggerCalled).toBe(true); // Should trigger even though state exists }); - test("writes progress messages to stderr when not in JSON mode", async () => { - let stderrOutput = ""; + test("writes progress messages to stdout when not in JSON mode", async () => { + let stdoutOutput = ""; let triggerCalled = false; - // Spy on process.stderr.write to capture logger output - const origWrite = process.stderr.write.bind(process.stderr); - process.stderr.write = ((chunk: string | Uint8Array) => { - stderrOutput += String(chunk); + // Force rich output so the spinner isn't suppressed in non-TTY test env + const origPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + + // Spy on process.stdout.write to capture spinner output + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + stdoutOutput += String(chunk); return true; - }) as typeof process.stderr.write; + }) as typeof process.stdout.write; try { // @ts-expect-error - partial mock @@ -1566,11 +1579,15 @@ describe("ensureRootCauseAnalysis", () => { json: false, // Not JSON mode, should output progress }); - // The logger.info() messages go through consola and the poll spinner - // writes directly to stderr — check for the spinner's initial message - expect(stderrOutput).toContain("Waiting for analysis"); + // The poll spinner writes to stdout — check for the spinner's initial message + expect(stdoutOutput).toContain("Waiting for analysis"); } finally { - process.stderr.write = origWrite; + process.stdout.write = origWrite; + if (origPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = origPlain; + } } }); });