Skip to content

Commit d4eaf17

Browse files
committed
fix(polling): move spinner from stderr to stdout to prevent consola collision
Move the spinner animation in startSpinner/withProgress/poll from process.stderr to process.stdout. Consola is configured to write exclusively to stderr, so when log.info() fires inside a withProgress callback (e.g. "Applied delta patch" during upgrade), it appends directly after the spinner text on the same stderr line, producing garbled output like: ⠹ Downloading 0.20.0-dev...ℹ Applied delta patch (74.9 KB downloaded) With the spinner on stdout and consola on stderr, the two streams no longer collide. Also adds isPlainOutput() suppression so the spinner is hidden when stdout is piped or in plain-output mode, preventing ANSI escape codes from contaminating pipe consumers.
1 parent 88b6f36 commit d4eaf17

File tree

2 files changed

+58
-35
lines changed

2 files changed

+58
-35
lines changed

src/lib/polling.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { TimeoutError } from "./errors.js";
9+
import { isPlainOutput } from "./formatters/plain-detect.js";
910
import {
1011
formatProgressLine,
1112
truncateProgressMessage,
@@ -82,7 +83,8 @@ export async function poll<T>(options: PollOptions<T>): Promise<T> {
8283
} = options;
8384

8485
const startTime = Date.now();
85-
const spinner = json ? null : startSpinner(initialMessage);
86+
const suppress = json || isPlainOutput();
87+
const spinner = suppress ? null : startSpinner(initialMessage);
8688

8789
try {
8890
while (Date.now() - startTime < timeoutMs) {
@@ -105,17 +107,21 @@ export async function poll<T>(options: PollOptions<T>): Promise<T> {
105107
throw new TimeoutError(timeoutMessage, timeoutHint);
106108
} finally {
107109
spinner?.stop();
108-
if (!json) {
109-
process.stderr.write("\n");
110+
if (!suppress) {
111+
process.stdout.write("\n");
110112
}
111113
}
112114
}
113115

114116
/**
115-
* Start an animated spinner that writes progress to stderr.
117+
* Start an animated spinner that writes progress to stdout.
118+
*
119+
* Uses stdout so the spinner doesn't collide with consola log messages
120+
* on stderr. The spinner is erased before command output is written,
121+
* and is suppressed entirely in JSON mode and when stdout is not a TTY.
116122
*
117123
* Returns a controller with `setMessage` to update the displayed text
118-
* and `stop` to halt the animation. Writes directly to `process.stderr`.
124+
* and `stop` to halt the animation.
119125
*/
120126
function startSpinner(initialMessage: string): {
121127
setMessage: (msg: string) => void;
@@ -130,7 +136,7 @@ function startSpinner(initialMessage: string): {
130136
return;
131137
}
132138
const display = truncateProgressMessage(currentMessage);
133-
process.stderr.write(`\r\x1b[K${formatProgressLine(display, tick)}`);
139+
process.stdout.write(`\r\x1b[K${formatProgressLine(display, tick)}`);
134140
tick += 1;
135141
setTimeout(scheduleFrame, ANIMATION_INTERVAL_MS).unref();
136142
};
@@ -158,15 +164,15 @@ export type WithProgressOptions = {
158164
};
159165

160166
/**
161-
* Run an async operation with an animated spinner on stderr.
167+
* Run an async operation with an animated spinner on stdout.
162168
*
163169
* The spinner uses the same braille frames as the Seer polling spinner,
164170
* giving a consistent look across all CLI commands. Progress output goes
165-
* to stderr, so it never contaminates stdout (safe to use alongside JSON output).
171+
* to stdout so it doesn't collide with consola log messages on stderr.
166172
*
167-
* When `options.json` is true the spinner is suppressed entirely, matching
168-
* the behaviour of {@link poll}. This avoids noisy ANSI escape sequences on
169-
* stderr when agents or CI pipelines consume `--json` output.
173+
* The spinner is suppressed when:
174+
* - `options.json` is true (JSON mode — no ANSI noise for agents/CI)
175+
* - stdout is not a TTY / plain output mode is active (piped output)
170176
*
171177
* The callback receives a `setMessage` function to update the displayed
172178
* message as work progresses (e.g. to show page counts during pagination).
@@ -193,10 +199,10 @@ export async function withProgress<T>(
193199
options: WithProgressOptions,
194200
fn: (setMessage: (msg: string) => void) => Promise<T>
195201
): Promise<T> {
196-
if (options.json) {
197-
// JSON mode: skip the spinner entirely, pass a no-op setMessage
202+
if (options.json || isPlainOutput()) {
203+
// JSON mode or non-TTY: skip the spinner entirely, pass a no-op setMessage
198204
return fn(() => {
199-
/* spinner suppressed in JSON mode */
205+
/* spinner suppressed */
200206
});
201207
}
202208

@@ -206,6 +212,6 @@ export async function withProgress<T>(
206212
return await fn(spinner.setMessage);
207213
} finally {
208214
spinner.stop();
209-
process.stderr.write("\r\x1b[K");
215+
process.stdout.write("\r\x1b[K");
210216
}
211217
}

test/commands/issue/utils.test.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,16 +1058,20 @@ describe("pollAutofixState", () => {
10581058
expect(fetchCount).toBe(2);
10591059
});
10601060

1061-
test("writes progress to stderr when not in JSON mode", async () => {
1062-
let stderrOutput = "";
1061+
test("writes progress to stdout when not in JSON mode", async () => {
1062+
let stdoutOutput = "";
10631063
let fetchCount = 0;
10641064

1065-
// Spy on process.stderr.write to capture spinner output
1066-
const origWrite = process.stderr.write.bind(process.stderr);
1067-
process.stderr.write = ((chunk: string | Uint8Array) => {
1068-
stderrOutput += String(chunk);
1065+
// Force rich output so the spinner isn't suppressed in non-TTY test env
1066+
const origPlain = process.env.SENTRY_PLAIN_OUTPUT;
1067+
process.env.SENTRY_PLAIN_OUTPUT = "0";
1068+
1069+
// Spy on process.stdout.write to capture spinner output
1070+
const origWrite = process.stdout.write.bind(process.stdout);
1071+
process.stdout.write = ((chunk: string | Uint8Array) => {
1072+
stdoutOutput += String(chunk);
10691073
return true;
1070-
}) as typeof process.stderr.write;
1074+
}) as typeof process.stdout.write;
10711075

10721076
try {
10731077
// Return PROCESSING first to allow animation interval to fire,
@@ -1127,9 +1131,14 @@ describe("pollAutofixState", () => {
11271131
pollIntervalMs: 100, // Allow animation interval (80ms) to fire
11281132
});
11291133

1130-
expect(stderrOutput).toContain("Analyzing");
1134+
expect(stdoutOutput).toContain("Analyzing");
11311135
} finally {
1132-
process.stderr.write = origWrite;
1136+
process.stdout.write = origWrite;
1137+
if (origPlain === undefined) {
1138+
delete process.env.SENTRY_PLAIN_OUTPUT;
1139+
} else {
1140+
process.env.SENTRY_PLAIN_OUTPUT = origPlain;
1141+
}
11331142
}
11341143
});
11351144

@@ -1505,16 +1514,20 @@ describe("ensureRootCauseAnalysis", () => {
15051514
expect(triggerCalled).toBe(true); // Should trigger even though state exists
15061515
});
15071516

1508-
test("writes progress messages to stderr when not in JSON mode", async () => {
1509-
let stderrOutput = "";
1517+
test("writes progress messages to stdout when not in JSON mode", async () => {
1518+
let stdoutOutput = "";
15101519
let triggerCalled = false;
15111520

1512-
// Spy on process.stderr.write to capture logger output
1513-
const origWrite = process.stderr.write.bind(process.stderr);
1514-
process.stderr.write = ((chunk: string | Uint8Array) => {
1515-
stderrOutput += String(chunk);
1521+
// Force rich output so the spinner isn't suppressed in non-TTY test env
1522+
const origPlain = process.env.SENTRY_PLAIN_OUTPUT;
1523+
process.env.SENTRY_PLAIN_OUTPUT = "0";
1524+
1525+
// Spy on process.stdout.write to capture spinner output
1526+
const origWrite = process.stdout.write.bind(process.stdout);
1527+
process.stdout.write = ((chunk: string | Uint8Array) => {
1528+
stdoutOutput += String(chunk);
15161529
return true;
1517-
}) as typeof process.stderr.write;
1530+
}) as typeof process.stdout.write;
15181531

15191532
try {
15201533
// @ts-expect-error - partial mock
@@ -1566,11 +1579,15 @@ describe("ensureRootCauseAnalysis", () => {
15661579
json: false, // Not JSON mode, should output progress
15671580
});
15681581

1569-
// The logger.info() messages go through consola and the poll spinner
1570-
// writes directly to stderr — check for the spinner's initial message
1571-
expect(stderrOutput).toContain("Waiting for analysis");
1582+
// The poll spinner writes to stdout — check for the spinner's initial message
1583+
expect(stdoutOutput).toContain("Waiting for analysis");
15721584
} finally {
1573-
process.stderr.write = origWrite;
1585+
process.stdout.write = origWrite;
1586+
if (origPlain === undefined) {
1587+
delete process.env.SENTRY_PLAIN_OUTPUT;
1588+
} else {
1589+
process.env.SENTRY_PLAIN_OUTPUT = origPlain;
1590+
}
15741591
}
15751592
});
15761593
});

0 commit comments

Comments
 (0)