diff --git a/packages/cli/src/ui/progress.test.ts b/packages/cli/src/ui/progress.test.ts new file mode 100644 index 000000000..659423773 --- /dev/null +++ b/packages/cli/src/ui/progress.test.ts @@ -0,0 +1,60 @@ +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; + const spyOnStdoutWrite = () => vi.spyOn(process.stdout, "write").mockImplementation(() => true); + let writeSpy: ReturnType; + + beforeEach(() => { + resetProgressThrottleForTests(); + writeSpy = spyOnStdoutWrite(); + }); + + 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`); }