Skip to content

Commit 6923168

Browse files
authored
fix(upgrade): delta apply progress bar shows percentage only (no GB scare) (#1355)
## What The delta-upgrade apply progress bar's byte total sums every hop's `newSize`, so for multi-hop chains it can far exceed the final binary — e.g. the user sees `Applying 3 patch(es) [████░░░░] 1.5 GB / 3.1 GB` for what ends up being a 310 MB install. A scary inflated number that ticks away without ever closing the gap. Switch the apply bar to render **percentage only** (no byte counter). The pre-apply phases (`download`, `read`) keep the byte counter since their totals are honest sizes. ## Why Mirror of the Lore fix at [BYK/loreai#1519](BYK/loreai#1519) (and follow-up #1522). Same code path — binpatch emits byte events with summed `newSize` across hops; the consumer bar formats them. ## How - **`packages/cli/src/lib/progress.ts`** — `makeByteProgress` gains `format: 'pct'` (default `'bytes'`). When set, the render is `${label} [${bar}] ${pct}%` (no `/` separator, no byte counts). Indeterminate byte counters (`totalBytes: null`) ignore the flag — they always show the live byte total. - **`packages/cli/src/lib/delta-upgrade.ts`** — apply phase passes `{ format: 'pct' }`. Pre-apply phases default to `'bytes'`. The phase-change branch was refactored to keep lint complexity ≤ 15. - **API churn**: the 4th positional arg (`nowMs`) moves into a `MakeByteProgressOptions` bag (`{ format?, nowMs? }`). This keeps the helper under the lint `useMaxParams: 4` budget; existing callers that passed `nowMs` (tests only) are updated in the same commit. The other call site in `upgrade.ts` (`streamDecompressToFile`) doesn't use `nowMs` and is unaffected — it falls through `options = {}` default. ## Tests - 3 new tests in `packages/cli/test/lib/progress.test.ts`: - pct-only rendering at 100% (`[████████] 100%`, no `B`/`KB`/`MB`/`GB`/`TB` substring, no `/`) - pct ignored for indeterminate (`null` total) counters — still shows live byte count, no `%` - Regression assertion: reads `delta-upgrade.ts` source and asserts the apply-phase `makeByteProgress` call passes `"pct"`. **Mutation-verified**: reverting the `pct` arg makes the test red. - All 10 `progress.test.ts` tests pass. - Full `test/lib` suite: **6540 passed | 9 skipped (6549)**, 308 files. - `pnpm run lint` clean. - `pnpm run typecheck` clean. - `pnpm run check:errors` clean. - `pnpm run check:deps` clean. ## Out of scope - Pre-apply (`download`/`read`) byte counters are intentional — they're honest sizes. - The `binpatch` library still emits `newSize`-summed totals; the consumer chooses how to render them (this matches the established `binpatch` contract — library emits, consumer renders).
1 parent 94a4ca5 commit 6923168

3 files changed

Lines changed: 190 additions & 34 deletions

File tree

packages/cli/src/lib/delta-upgrade.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -404,16 +404,27 @@ function makeProgressHandler(setMessage?: SetMessage): ProgressHandler {
404404
let phase: string | undefined;
405405
let previousWritten = 0;
406406
return (event) => {
407-
if (event.type === "bytes") {
408-
if (!progress || phase !== event.phase) {
409-
phase = event.phase;
410-
previousWritten = 0;
411-
progress = makeByteProgress(
412-
`${event.phase === "apply" ? "Applying" : "Processing"} patch(es)`,
413-
event.total,
414-
setMessage
415-
);
416-
}
407+
if (
408+
event.type === "bytes" &&
409+
(progress === undefined || phase !== event.phase)
410+
) {
411+
// New phase: spin up a fresh bar. The apply phase totals bytes across
412+
// every hop's `newSize`, which for multi-hop chains far exceeds the
413+
// final binary (e.g. 930 MB shown for a 310 MB install). Switch to
414+
// percent-only rendering so users see a sane progress fraction rather
415+
// than a scary inflated byte count. Pre-apply ("download"/"read")
416+
// phases still show bytes since their totals are honest sizes.
417+
phase = event.phase;
418+
previousWritten = 0;
419+
const isApply = event.phase === "apply";
420+
progress = makeByteProgress(
421+
`${isApply ? "Applying" : "Processing"} patch(es)`,
422+
event.total,
423+
setMessage,
424+
{ format: isApply ? "pct" : "bytes" }
425+
);
426+
}
427+
if (event.type === "bytes" && progress) {
417428
progress.onProgress(event.written - previousWritten);
418429
previousWritten = event.written;
419430
} else if (event.type === "done") {

packages/cli/src/lib/progress.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ import { formatBytes } from "./formatters/numbers.js";
2121
/** Callback that sets the surrounding spinner's message text. */
2222
export type SetMessage = (message: string) => void;
2323

24+
/**
25+
* How a determinate bar renders its progress fraction.
26+
*
27+
* - `"bytes"` (default) renders the accumulated/total byte count alongside the
28+
* bar — useful when the total is a meaningful size the user can reason about
29+
* (e.g. a full-binary download).
30+
* - `"pct"` renders only the percentage — useful when the byte total is
31+
* misleading (e.g. a multi-hop patch chain whose summed `newSize` far
32+
* exceeds the final binary, so "1.5 GB / 3.1 GB" would scare the user
33+
* about an install that's actually 310 MB).
34+
*/
35+
export type ByteProgressFormat = "bytes" | "pct";
36+
2437
export type ByteProgress = {
2538
/** Report `bytes` additional bytes processed since the last call. */
2639
onProgress: (bytes: number) => void;
@@ -37,6 +50,24 @@ function renderBar(frac: number, width = BAR_WIDTH): string {
3750
return "█".repeat(filled) + "░".repeat(width - filled);
3851
}
3952

53+
/**
54+
* Options for {@link makeByteProgress} beyond the first three required args.
55+
*
56+
* Kept as a separate options bag so the helper stays under the lint max-params
57+
* budget (`useMaxParams: 4`) — four-position call sites don't change.
58+
*/
59+
export type MakeByteProgressOptions = {
60+
/**
61+
* Determinate render format. `"bytes"` (default) shows the accumulated/total
62+
* byte count; `"pct"` shows only the percentage (no byte counter). Ignored
63+
* when `totalBytes` is null — indeterminate byte counters always show the
64+
* live byte total.
65+
*/
66+
format?: ByteProgressFormat;
67+
/** Injectable clock for tests. Defaults to `Date.now`. */
68+
nowMs?: () => number;
69+
};
70+
4071
/**
4172
* Create a byte-progress reporter that feeds a spinner `setMessage` callback.
4273
*
@@ -46,23 +77,27 @@ function renderBar(frac: number, width = BAR_WIDTH): string {
4677
* @param setMessage - Spinner message setter (from `withProgress`). When
4778
* undefined (JSON mode / non-TTY / no surrounding spinner), this is a no-op
4879
* so nothing is drawn.
49-
* @param nowMs - Injectable clock for tests.
80+
* @param options - Optional render tweaks (format, injectable clock).
5081
*/
5182
export function makeByteProgress(
5283
label: string,
5384
totalBytes: number | null,
54-
setMessage?: SetMessage,
55-
nowMs: () => number = Date.now
85+
setMessage: SetMessage | undefined,
86+
options: MakeByteProgressOptions = {}
5687
): ByteProgress {
88+
const { format = "bytes", nowMs = Date.now } = options;
5789
let written = 0;
5890
let lastEmit = 0;
5991

60-
const format = (): string => {
92+
const render = (): string => {
6193
if (totalBytes === null || totalBytes <= 0) {
6294
return `${label} ${formatBytes(written)}`;
6395
}
6496
const frac = Math.min(written / totalBytes, 1);
6597
const pct = Math.round(frac * 100);
98+
if (format === "pct") {
99+
return `${label} [${renderBar(frac)}] ${pct}%`;
100+
}
66101
return (
67102
`${label} [${renderBar(frac)}] ` +
68103
`${formatBytes(written)} / ${formatBytes(totalBytes)} (${pct}%)`
@@ -72,7 +107,7 @@ export function makeByteProgress(
72107
const emit = (): void => {
73108
// Cosmetic only — a formatting or callback failure must never propagate.
74109
try {
75-
setMessage?.(format());
110+
setMessage?.(render());
76111
} catch {
77112
// ignore — progress is cosmetic
78113
}

packages/cli/test/lib/progress.test.ts

Lines changed: 129 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@
88
*
99
* Coverage: determinate vs indeterminate formatting, byte accumulation,
1010
* full-bar clamping, update throttling, a final un-throttled done() emit, the
11-
* no-op path when no setMessage is provided, and the never-throws contract.
11+
* no-op path when no setMessage is provided, the never-throws contract, and
12+
* the `format: "pct"` mode that suppresses the inflated byte counter for
13+
* multi-hop patch chains.
1214
*/
1315

16+
import { readFileSync } from "node:fs";
17+
import { join } from "node:path";
18+
1419
import { describe, expect, test, vi } from "vitest";
1520

1621
import { makeByteProgress } from "../../src/lib/progress.js";
@@ -23,7 +28,9 @@ describe("makeByteProgress", () => {
2328
"Applying 3 patch(es)",
2429
1000,
2530
(m) => msgs.push(m),
26-
() => now
31+
{
32+
nowMs: () => now,
33+
}
2734
);
2835
p.onProgress(500); // first call: now=0, lastEmit=0 → throttled (0-0 < 100)
2936
now = 200;
@@ -38,12 +45,9 @@ describe("makeByteProgress", () => {
3845
test("formats an indeterminate byte counter when total is null", () => {
3946
const msgs: string[] = [];
4047
let now = 0;
41-
const p = makeByteProgress(
42-
"Downloading",
43-
null,
44-
(m) => msgs.push(m),
45-
() => now
46-
);
48+
const p = makeByteProgress("Downloading", null, (m) => msgs.push(m), {
49+
nowMs: () => now,
50+
});
4751
now = 200;
4852
p.onProgress(2048);
4953
const last = msgs.at(-1) ?? "";
@@ -55,12 +59,9 @@ describe("makeByteProgress", () => {
5559
test("accumulates bytes across calls and clamps the bar at full", () => {
5660
const msgs: string[] = [];
5761
let now = 0;
58-
const p = makeByteProgress(
59-
"Applying",
60-
100,
61-
(m) => msgs.push(m),
62-
() => now
63-
);
62+
const p = makeByteProgress("Applying", 100, (m) => msgs.push(m), {
63+
nowMs: () => now,
64+
});
6465
p.onProgress(50);
6566
now = 500;
6667
p.onProgress(9000); // way over total → clamp to 100%
@@ -73,9 +74,9 @@ describe("makeByteProgress", () => {
7374
test("throttles updates so a fast byte stream doesn't spam the spinner", () => {
7475
const set = vi.fn();
7576
const now = 1000;
76-
const p = makeByteProgress("Applying", 1000, set, () => now);
77+
const p = makeByteProgress("Applying", 1000, set, { nowMs: () => now });
7778
// Many calls within the same throttle window → at most one emit.
78-
for (let i = 0; i < 50; i++) {
79+
for (let i = 0; i < 50; i += 1) {
7980
p.onProgress(10);
8081
}
8182
expect(set.mock.calls.length).toBeLessThanOrEqual(1);
@@ -84,7 +85,7 @@ describe("makeByteProgress", () => {
8485
test("done() emits a final, un-throttled message reflecting the total", () => {
8586
const set = vi.fn();
8687
const now = 0;
87-
const p = makeByteProgress("Applying", 100, set, () => now);
88+
const p = makeByteProgress("Applying", 100, set, { nowMs: () => now });
8889
p.onProgress(100); // throttled (now-0 < 100), no emit yet
8990
set.mockClear();
9091
p.done(); // must emit regardless of throttle
@@ -95,7 +96,7 @@ describe("makeByteProgress", () => {
9596
test("is a no-op when no setMessage is provided (JSON/non-TTY)", () => {
9697
// Nothing to assert beyond: it must not throw and must still track bytes
9798
// so a later done() with a setMessage-less reporter is harmless.
98-
const p = makeByteProgress("Applying", 100);
99+
const p = makeByteProgress("Applying", 100, undefined);
99100
expect(() => {
100101
p.onProgress(50);
101102
p.done();
@@ -109,11 +110,120 @@ describe("makeByteProgress", () => {
109110
() => {
110111
throw new Error("boom");
111112
},
112-
() => 1000
113+
{ nowMs: () => 1000 }
113114
);
114115
expect(() => {
115116
p.onProgress(100);
116117
p.done();
117118
}).not.toThrow();
118119
});
120+
121+
test("renders percentage only when format='pct' (apply bar suppresses GB scare)", () => {
122+
// Multi-hop chains sum newSize across hops, so event.total can far
123+
// exceed the final binary size (e.g. 930 MB for a 3-hop 310 MB chain).
124+
// The apply bar should show only percentage in that case so users
125+
// don't see "applied 1.5 GB / 3.1 GB" for what ends up being a
126+
// 310 MB install.
127+
const msgs: string[] = [];
128+
let now = 0;
129+
const p = makeByteProgress(
130+
"Applying 3 patch(es)",
131+
930 * 1024 * 1024,
132+
(m) => msgs.push(m),
133+
{ format: "pct", nowMs: () => now }
134+
);
135+
now = 200;
136+
p.onProgress(310 * 1024 * 1024); // 33%
137+
now = 400;
138+
p.onProgress(310 * 1024 * 1024); // 66%
139+
now = 600;
140+
p.onProgress(310 * 1024 * 1024); // 100%
141+
p.done();
142+
143+
const last = msgs.at(-1) ?? "";
144+
expect(last).toContain("Applying 3 patch(es)");
145+
expect(last).toMatch(/\[+*\] 100%/);
146+
// No byte count in pct mode
147+
expect(last).not.toMatch(/\d+\s*(B|KB|MB|GB|TB)/);
148+
expect(last).not.toContain("/");
149+
});
150+
151+
test("format='pct' is ignored for indeterminate (null total) byte counters", () => {
152+
// Indeterminate bars have no meaningful percentage — they always show
153+
// the live byte total regardless of the format option.
154+
const msgs: string[] = [];
155+
let now = 0;
156+
const p = makeByteProgress("Downloading", null, (m) => msgs.push(m), {
157+
format: "pct",
158+
nowMs: () => now,
159+
});
160+
now = 200;
161+
p.onProgress(2048);
162+
const last = msgs.at(-1) ?? "";
163+
expect(last).toContain("Downloading");
164+
expect(last).toContain("2.0 KB");
165+
expect(last).not.toContain("%");
166+
});
167+
168+
test("apply bar call site passes 'pct' format (regression: multi-hop GB scare)", () => {
169+
// Regression: delta-upgrade.ts apply bar previously called
170+
// makeByteProgress(label, total, setMessage) without passing format, so
171+
// the bar fell back to bytes mode and the "pct only" UX never applied.
172+
// Reads the source to assert the apply-phase call site actually wires
173+
// "pct", so a future regression to bytes mode is caught even if the
174+
// helper itself still defaults to "bytes".
175+
const src = readFileSync(
176+
join(__dirname, "../../src/lib/delta-upgrade.ts"),
177+
"utf8"
178+
);
179+
// Find the makeByteProgress call inside the apply bar branch by scanning
180+
// for balanced parentheses, so nested calls like
181+
// makeByteProgress("label", computeTotal()) still match. Regex-based
182+
// extraction breaks on nested parens, which a real refactor could
183+
// easily introduce.
184+
const matches = extractCalls(src, "makeByteProgress");
185+
const applyCall = matches.find((m) => m.includes("isApply"));
186+
expect(applyCall).toBeDefined();
187+
expect(applyCall).toMatch(/["']pct["']/);
188+
});
119189
});
190+
191+
/**
192+
* Walk a source string and return every `name(`...`)` call as a string slice.
193+
* Uses a balanced-parentheses scan rather than a regex so nested calls like
194+
* `makeByteProgress("label", computeTotal())` still extract cleanly.
195+
*/
196+
function extractCalls(source: string, name: string): string[] {
197+
const out: string[] = [];
198+
let i = 0;
199+
while (i < source.length) {
200+
const found = source.indexOf(name, i);
201+
if (found === -1) {
202+
break;
203+
}
204+
const open = source.indexOf("(", found);
205+
if (open === -1) {
206+
break;
207+
}
208+
let depth = 1;
209+
let j = open + 1;
210+
while (j < source.length && depth > 0) {
211+
const ch = source[j];
212+
if (ch === "(") {
213+
depth += 1;
214+
} else if (ch === ")") {
215+
depth -= 1;
216+
}
217+
if (depth > 0) {
218+
j += 1;
219+
}
220+
}
221+
if (depth === 0) {
222+
out.push(source.slice(found, j + 1));
223+
i = j + 1;
224+
} else {
225+
break; // unbalanced, stop scanning
226+
}
227+
}
228+
return out;
229+
}

0 commit comments

Comments
 (0)