From 65f0874d95b8c0a484ee474f02d623eff43e4267 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 3 Jul 2026 04:24:55 -0700 Subject: [PATCH 1/2] fix(cli): make render progress visible when stdout is piped renderProgress's non-TTY fallback still wrote a carriage-return line with ANSI cursor-erase codes, unthrottled, on every progress tick. A piped or redirected stdout has no cursor to move, and Node full-buffers non-TTY streams by default, so these writes accumulated invisibly until the process exited rather than ever appearing in piped/logged output - reported as "progress output invisible when stdout piped (buffered until exit)". Non-TTY stdout now gets plain, newline-terminated lines instead, throttled to one per integer percent so a long render doesn't spam a log file with hundreds of near-identical lines. TTY behavior (the interactive carriage-return progress bar) is unchanged. --- packages/cli/src/ui/progress.test.ts | 59 ++++++++++++++++++++++++++++ packages/cli/src/ui/progress.ts | 33 ++++++++++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/ui/progress.test.ts diff --git a/packages/cli/src/ui/progress.test.ts b/packages/cli/src/ui/progress.test.ts new file mode 100644 index 000000000..85cf6291d --- /dev/null +++ b/packages/cli/src/ui/progress.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderProgress, resetProgressThrottleForTests } from "./progress.js"; + +// Regression: a piped/redirected stdout has no cursor and Node full-buffers +// non-TTY streams by default, so the old carriage-return progress bar +// (relying on \r + ANSI cursor codes, with no throttling) never became +// visible when render output was piped or logged — two independent reports +// named this ("progress output invisible when stdout piped", "doctor +// printed no output in non-TTY"). +describe("renderProgress", () => { + const originalIsTTY = process.stdout.isTTY; + let writeSpy: ReturnType; + + beforeEach(() => { + resetProgressThrottleForTests(); + writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + }); + + afterEach(() => { + writeSpy.mockRestore(); + process.stdout.isTTY = originalIsTTY; + }); + + it("writes a carriage-return, cursor-based line on a TTY", () => { + process.stdout.isTTY = true; + renderProgress(50, "encoding"); + const written = writeSpy.mock.calls[0]?.[0] as string; + expect(written.startsWith("\r")).toBe(true); + expect(written).not.toMatch(/\n$/); + }); + + it("writes a plain newline-terminated line on non-TTY stdout", () => { + process.stdout.isTTY = false; + renderProgress(50, "encoding"); + const written = writeSpy.mock.calls[0]?.[0] as string; + expect(written).toBe("50% encoding\n"); + }); + + it("throttles non-TTY output to one line per integer percent", () => { + process.stdout.isTTY = false; + renderProgress(50.1, "encoding"); + renderProgress(50.2, "encoding"); + renderProgress(50.4, "encoding"); // Math.round(50.4) === 50, same bucket + expect(writeSpy).toHaveBeenCalledTimes(1); + + renderProgress(51.0, "encoding"); + expect(writeSpy).toHaveBeenCalledTimes(2); + expect(writeSpy.mock.calls[1]?.[0]).toBe("51% encoding\n"); + }); + + it("does not throttle across resets (each render invocation starts fresh)", () => { + process.stdout.isTTY = false; + renderProgress(100, "done"); + expect(writeSpy).toHaveBeenCalledTimes(1); + resetProgressThrottleForTests(); + renderProgress(100, "done again"); + expect(writeSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/src/ui/progress.ts b/packages/cli/src/ui/progress.ts index f4aa4c3e4..40f112ee5 100644 --- a/packages/cli/src/ui/progress.ts +++ b/packages/cli/src/ui/progress.ts @@ -2,6 +2,16 @@ import { c } from "./colors.js"; const { stdout } = process; +// Tracks the last integer percent written to a non-TTY stdout, so piped/ +// logged output isn't spammed with one line per progress tick. Module-level +// is fine here \u2014 the CLI is one render per process invocation. +let lastNonTtyPercent: number | undefined; + +/** Resets non-TTY throttling state. Exported for tests only. */ +export function resetProgressThrottleForTests(): void { + lastNonTtyPercent = undefined; +} + export function renderProgress(percent: number, stage: string, row?: number): void { const width = 25; const filled = Math.floor(percent / (100 / width)); @@ -10,9 +20,24 @@ export function renderProgress(percent: number, stage: string, row?: number): vo const line = ` ${bar} ${c.bold(String(Math.round(percent)) + "%")} ${c.dim(stage)}`; - if (row !== undefined && stdout.isTTY) { - stdout.write(`\x1b[${row};1H\x1b[2K${line}`); - } else { - stdout.write(`\r\x1b[2K${line}`); + if (stdout.isTTY) { + if (row !== undefined) { + stdout.write(`\x1b[${row};1H\x1b[2K${line}`); + } else { + stdout.write(`\r\x1b[2K${line}`); + } + return; } + + // A piped/redirected stdout has no cursor, and Node full-buffers + // non-TTY streams by default \u2014 so carriage-return progress bars (relying + // on \r and ANSI cursor codes) silently accumulate unseen until the + // process exits, rather than ever becoming visible. Emit plain, + // newline-terminated lines instead, throttled to one per integer percent + // so a long render doesn't spam a log file with hundreds of near- + // identical lines. + const roundedPercent = Math.round(percent); + if (roundedPercent === lastNonTtyPercent) return; + lastNonTtyPercent = roundedPercent; + stdout.write(`${roundedPercent}% ${stage}\n`); } From f0e657ad9f4a374026ebec09d88dc1c9b767b590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 4 Jul 2026 19:51:06 +0000 Subject: [PATCH 2/2] fix(cli): type progress stdout spy --- packages/cli/src/ui/progress.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/progress.test.ts b/packages/cli/src/ui/progress.test.ts index 85cf6291d..659423773 100644 --- a/packages/cli/src/ui/progress.test.ts +++ b/packages/cli/src/ui/progress.test.ts @@ -9,11 +9,12 @@ import { renderProgress, resetProgressThrottleForTests } from "./progress.js"; // printed no output in non-TTY"). describe("renderProgress", () => { const originalIsTTY = process.stdout.isTTY; - let writeSpy: ReturnType; + const spyOnStdoutWrite = () => vi.spyOn(process.stdout, "write").mockImplementation(() => true); + let writeSpy: ReturnType; beforeEach(() => { resetProgressThrottleForTests(); - writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + writeSpy = spyOnStdoutWrite(); }); afterEach(() => {