Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 21 additions & 15 deletions src/lib/polling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { TimeoutError } from "./errors.js";
import { isPlainOutput } from "./formatters/plain-detect.js";
import {
formatProgressLine,
truncateProgressMessage,
Expand Down Expand Up @@ -82,7 +83,8 @@ export async function poll<T>(options: PollOptions<T>): Promise<T> {
} = 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) {
Expand All @@ -105,17 +107,21 @@ export async function poll<T>(options: PollOptions<T>): Promise<T> {
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;
Expand All @@ -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();
};
Expand Down Expand Up @@ -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).
Expand All @@ -193,10 +199,10 @@ export async function withProgress<T>(
options: WithProgressOptions,
fn: (setMessage: (msg: string) => void) => Promise<T>
): Promise<T> {
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 */
});
}

Expand All @@ -206,6 +212,6 @@ export async function withProgress<T>(
return await fn(spinner.setMessage);
} finally {
spinner.stop();
process.stderr.write("\r\x1b[K");
process.stdout.write("\r\x1b[K");
}
}
24 changes: 17 additions & 7 deletions test/commands/cli/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
57 changes: 37 additions & 20 deletions test/commands/issue/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
}
});

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
});
});
Expand Down
Loading