Skip to content
Open
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
60 changes: 60 additions & 0 deletions packages/cli/src/ui/progress.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof spyOnStdoutWrite>;

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);
});
});
33 changes: 29 additions & 4 deletions packages/cli/src/ui/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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`);
}
Loading