From 6dd9cbc629e9b17c7cae8e5a1cf8b213845936dd Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 4 Oct 2026 21:23:33 +0000 Subject: [PATCH] fix: use event-driven renderer for build progress --- command/up.ts | 12 ++++++--- tests/command/up-api.test.ts | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/command/up.ts b/command/up.ts index b561329..8693e54 100644 --- a/command/up.ts +++ b/command/up.ts @@ -738,7 +738,7 @@ export async function runUp( }; const workspacePath = `/workspaces/${encodeURIComponent(project)}`; - const taskCtx = await new Listr( + const taskCtx = await new Listr( [ { title: "Read compose", @@ -796,7 +796,7 @@ export async function runUp( cwd, { progress: (message) => { - task.output = message; + if (task.output !== message) task.output = message; }, service: (name) => { let stream: ReturnType["stdout"]> | undefined; @@ -1071,7 +1071,13 @@ export async function runUp( }, }, ], - { rendererOptions: { collapseErrors: false } }, + { + // Build progress can remain queued across many polls. The default TTY + // spinner redraws every child on every tick, which duplicates those + // frames in captured terminal output. Simple emits only task events. + renderer: build ? "simple" : "default", + rendererOptions: { collapseErrors: false }, + }, ).run(); return taskCtx; diff --git a/tests/command/up-api.test.ts b/tests/command/up-api.test.ts index 2f0a7be..be00633 100644 --- a/tests/command/up-api.test.ts +++ b/tests/command/up-api.test.ts @@ -26,6 +26,56 @@ const snapshot = { }; describe("up API pipeline", () => { + test("does not append queued build frames on repeated TTY progress ticks", async () => { + const root = await mkdtemp(join(tmpdir(), "kuber-up-api-")); + const previousCwd = process.cwd(); + const tty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + const rendered: string[] = []; + const writes = spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => { + rendered.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + let polls = 0; + try { + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); + await writeFile(join(root, "compose.yml"), "services:\n app:\n build: .\n"); + await writeFile(join(root, ".kuberrc.ts"), 'export default { project: "shop" };\n'); + process.chdir(root); + const trust = await resolveTrustIdentity("shop", root); + const request: ApiRequester = async (path: string) => { + if (path === "/snapshots/negotiate") return { ready: true } as T; + if (path === "/builds") return { state: "queued" } as T; + if (path.includes("/events")) + return [{ type: "status", status: { + state: "queued", + phase: ["queued", "preparing", "waiting", "waiting"][polls], + } }] as T; + if (path.endsWith("/reconcile")) { + polls++; + await Bun.sleep(110); + return polls < 3 + ? { state: "queued" } as T + : { state: "failed", error: "build stopped" } as T; + } + throw new Error(path); + }; + await expect(provideContext(() => runUp(true, request, { trust }))).rejects.toThrow("build stopped"); + const output = rendered.join(""); + expect(polls).toBe(3); + expect(output.match(/Build app/g)).toHaveLength(1); + expect(output.match(/Build queued/g)).toHaveLength(1); + expect(output).toContain("Build preparing"); + expect(output).toContain("Build waiting"); + expect(output).toContain("build stopped"); + } finally { + writes.mockRestore(); + if (tty) Object.defineProperty(process.stdout, "isTTY", tty); + else Reflect.deleteProperty(process.stdout, "isTTY"); + process.chdir(previousCwd); + await rm(root, { recursive: true, force: true }); + } + }, 5_000); + test("attaches each build log to its started image child", async () => { const root = await mkdtemp(join(tmpdir(), "kuber-up-api-")); const previousCwd = process.cwd();