1// a trivial example of how to use progress
2test("trivial end-to-end example", () => {
3 const { fgBlue: FB, fgReset: FR, reset: R } = ansi;
4 const screen = new testing.MockScreen(); // create a mock terminal
5 const root = new progress.Root(screen); // sync with mock timers
6 progress.attachToScreen(root, screen); // attach `root` to the `screen`
7 assert.equal(screen.timers.entries.length, 0); // attaching does not render
8
9 const a = root.start("hello");
10 const b = root.start("cats");
11 const _ = b.start("meow");
12
13 assert.equal(screen.timers.entries.length, 1);
14
15 screen.expectFrame(1, { merged: "" }); // debounce
16 // widget lines containing escapes get a trailing ansi.reset from the
17 // renderer, so a malformed format function cannot bleed styles
18 screen.expectFrame(0, {
19 merged: testing.MockScreen.sync([
20 `${FB}⠋${FR} hello${R}\n`,
21 `${FB}⠋${FR} cats${R}\n`,
22 "└─ meow\n",
23 ]),
24 });
25
26 // spinners animate from the clock, so frames tick at the spinner rate
27 screen.expectFrame(80, {
28 merged: testing.MockScreen.sync([
29 ansi.cursorUp(3),
30 `${FB}⠙${FR} hello${R}\n`,
31 `${FB}⠙${FR} cats${R}\n`,
32 "└─ meow\n",
33 ]),
34 });
35
36 a.end();
37 b.end();
38
39 // ending fires the change debounce, which stops the widget and clears it
40 screen.expectFrame(1, { merged: "" });
41 screen.expectFrame(0, {
42 merged: testing.MockScreen.sync([
43 ansi.cursorUp(1) + ansi.clearFullLine,
44 ansi.cursorUp(1) + ansi.clearFullLine,
45 ansi.cursorUp(1) + ansi.clearFullLine,
46 ]),
47 });
48 screen.expectNone();
49});
50
51test("bars without estimates do not animate", () => {
52 const screen = new testing.MockScreen();
53 const root = new progress.Root(screen);
54 progress.attachToScreen(root, screen);
55
56 const a = root.start("download", { total: 4, value: 1 });
57 screen.expectFrame(1, { merged: "" }); // debounce
58 screen.expectFrame(0, {}); // initial draw
59 // a bar is static between value changes, so no frame timer is pending
60 screen.expectNone();
61
62 a.inc();
63 screen.expectFrame(1, { merged: "" });
64 screen.expectFrame(0, {});
65 screen.expectNone();
66
67 a.end();
68 screen.expectFrame(1, { merged: "" });
69 screen.expectFrame(0, {});
70 screen.expectNone();
71});
72
73test("attachToScreen dispose detaches from the root", () => {
74 const screen = new testing.MockScreen();
75 const root = new progress.Root(screen);
76 const detach = progress.attachToScreen(root, screen);
77
78 const a = root.start("hello");
79 screen.expectFrame(1, { merged: "" });
80 screen.expectFrame(0, {
81 stderr: testing.MockScreen.sync([
82 `${ansi.style(ansi.fgBlue, "⠋")} hello${ansi.reset}\n`,
83 ]),
84 });
85
86 detach[Symbol.dispose]();
87 // the widget is stopped and removed from the screen on detach
88 screen.expectFrame(0, {
89 stderr: testing.MockScreen.sync([
90 ansi.cursorUp(1),
91 ansi.clearFullLine,
92 ]),
93 });
94
95 // further progress activity no longer renders
96 a.inc();
97 screen.expectFrame(1, { merged: "" }); // root's own debounce still runs
98 screen.expectNone();
99});
100
101describe("encodeEventStream", (t) => {
102 test("simple", async () => {
103 const root = new progress.Root(); // sync with mock timers
104
105 const a = root.start("hello");
106 const b = root.start("cats", { value: 2, total: 5 });
107
108 const events = progress.encodeEventStream(root);
109 const reader = events.getReader();
110 const next = async () => cleanProgressEvent(UNWRAP((await reader.read()).value));
111
112 assert.deepEqual(await next(), [
113 -1,
114 { k: 1, t: "hello" },
115 { k: 2, t: "cats", v: 2, e: 5 },
116 [1, 2],
117 ]);
118 b.inc();
119 b.inc();
120 assert.deepEqual(await next(), [
121 0,
122 { k: 2, v: 4 },
123 ]);
124 b.inc();
125 assert.deepEqual(await next(), [
126 1,
127 2,
128 [1],
129 ]);
130 });
131
132 test("end closes the stream and decode resolves", async () => {
133 const root = new progress.Root<number>();
134 const a = root.start("task");
135 const handle = progress.decodeEventStream<number>(
136 progress.encodeEventStream(root),
137 progress.nullNode,
138 );
139 a.end();
140 root.end(42);
141 assert.equal(await handle, 42);
142 });
143
144 test("error rejects the decode promise", async () => {
145 const root = new progress.Root();
146 root.start("task");
147 const handle = progress.decodeEventStream(
148 progress.encodeEventStream(root),
149 progress.nullNode,
150 );
151 root.error(new Error("compute exploded"));
152 await assert.rejects(handle, /compute exploded/);
153 });
154
155 test("custom events pass through", async () => {
156 const root = new progress.Root<number, { meow: [string] }>();
157 root.start("task");
158 const handle = progress.decodeEventStream<number, { meow: [string] }>(
159 progress.encodeEventStream(root),
160 progress.nullNode,
161 );
162 const got: string[] = [];
163 handle.on("meow", (s) => void got.push(s));
164 root.emit("meow", "purr");
165 root.end(1);
166 assert.equal(await handle, 1);
167 assert.deepEqual(got, ["purr"]);
168 });
169
170 test("a stream that closes without end rejects", async () => {
171 const encoded = new ReadableStream<progress.StreamEvent>({
172 start(c) {
173 c.enqueue([-1]);
174 c.close();
175 },
176 });
177 const handle = progress.decodeEventStream(encoded, progress.nullNode);
178 await assert.rejects(handle, /Stream terminated early/);
179 });
180
181 test("events after a long idle gap are not throttled", async () => {
182 let t = 0;
183 const delays: number[] = [];
184 const root = new progress.Root({
185 now: () => t,
186 delay: (ms) => (delays.push(ms), async.delay(0)),
187 });
188 progress.encodeEventStream(root);
189 t = 1000;
190 // regression: the throttle used to wait `idle + throttleMs` instead of
191 // `throttleMs - idle`, so this would schedule a ~1033ms delay
192 root.start("x");
193 assert.ok(
194 delays.every((ms) => ms <= 1000 / 30 + 1),
195 `delays: ${delays.join(", ")}`,
196 );
197 });
198
199 test("byte stream round trip with end", async () => {
200 const root = new progress.Root<string>();
201 const a = root.start("task", { total: 3 });
202 const handle = progress.decodeByteStream<string>(
203 progress.encodeByteStream(root),
204 progress.nullNode,
205 );
206 a.inc();
207 a.end();
208 root.end("done");
209 assert.equal(await handle, "done");
210 });
211});
212
213test("child truncation ends with a newline", () => {
214 const root = new progress.Root();
215 const a = root.start("many");
216 for (let i = 0; i < 55; i += 1) a.start(`item ${i}`);
217 root.start("after");
218 const lines = ansi.strip(progress.formatAnsi(0, root.active)).split("\n");
219 const trunc = lines.indexOf("└─ [5 more]");
220 assert.notEqual(trunc, -1, "expected a truncation indicator");
221 // regression: the indicator had no trailing newline, so the next top-level
222 // node rendered on the same line
223 assert.equal(lines[trunc + 1], "⠋ after");
224});
225
226// ## value formatters
227test("slashValueFormatter", () => {
228 const fn = vi.fn((arg: number) => `<${arg}>`);
229
230 const fmt = progress.slashValueFormatter(fn);
231 assert.equal(fmt(250, 0), "<250>");
232 assert.equal(fn.mock.calls.length, 1);
233 assert.equal(fmt(250, 450), "<250>/<450>");
234 assert.equal(fn.mock.calls.length, 3);
235});
236test("bytesValueFormatter", () => {
237 assert.equal(progress.bytesValueFormatter(250, 0), "250B");
238 assert.equal(progress.bytesValueFormatter(50_000, 1_200_000), "50kB/1.20MB");
239 assert.equal(progress.bytesValueFormatter(0, 0), "");
240});
241test("defaultValueFormatter", () => {
242 assert.equal(progress.defaultValueFormatter(250, 0), "250");
243 assert.equal(progress.defaultValueFormatter(52, 1_200_000), "52/1200000");
244 assert.equal(progress.defaultValueFormatter(0, 1_200_000), "");
245});
246
247test("percentValueFormatter", () => {
248 assert.equal(progress.percentValueFormatter(100, 200), "50%");
249 assert.equal(progress.percentValueFormatter(400, 400), "100%");
250 assert.equal(progress.percentValueFormatter(0, 1_200_000), "0%");
251 assert.equal(progress.percentValueFormatter(0, null), "0%");
252 assert.equal(progress.percentValueFormatter(0.5, null), "50%");
253});
254
255test("formatAnsi hides a parent estimate shorter than a recursive child", () => {
256 const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000);
257 try {
258 const child: progress.ReadOnlyNode = {
259 key: 2,
260 text: "child",
261 value: 1,
262 total: 4,
263 estimatedTime: 1_040_000,
264 passive: false,
265 hidden: false,
266 showTotal: true,
267 sortChildren: null,
268 valueFormatter: progress.defaultValueFormatter,
269 logs: [],
270 children: [],
271 parent: null as unknown as progress.ReadOnlyNode,
272 };
273 const parent: progress.ReadOnlyNode = {
274 key: 1,
275 text: "parent",
276 value: 1,
277 total: 4,
278 estimatedTime: 1_020_000,
279 passive: false,
280 hidden: false,
281 showTotal: true,
282 sortChildren: null,
283 valueFormatter: progress.defaultValueFormatter,
284 logs: [],
285 children: [child],
286 parent: null,
287 };
288 (child as { parent: progress.ReadOnlyNode }).parent = parent;
289
290 const rendered = progress.formatAnsi(0, [parent]);
291 assert.match(rendered, /parent/);
292 assert.match(rendered, /child/);
293 assert.doesNotMatch(rendered, /parent.*20s/);
294 assert.match(rendered, /40s.*child/);
295 } finally {
296 now.mockRestore();
297 }
298});
299
300test("Ema tracks warmup samples without resetting the first sample", () => {
301 const ema = new progress.Ema();
302 assert.equal(ema.sample(1_000, 0, 100), null);
303 assert.equal(ema.sample(9_000, 0, 100), null);
304 assert.equal(ema.start, 1_000);
305 assert.equal(ema.sample(10_000, 50, 100), null);
306 assert.equal(ema.estimate, 0.25 * (50 / 9_000));
307 const completion = ema.sample(11_000, 60, 100);
308 assert.ok(completion != null);
309 assert.ok(Math.abs(completion - 26_737.70491803279) < 1e-9);
310});
311
312// ## internals
313test("event encoding round trip cases", async () => {
314 const fakeNode = progress.nullNode;
315 async function roundTrip(event: progress.StreamEvent) {
316 const [readable, controller] = stream.readWritePair();
317 const chunks: Uint8Array[] = [];
318 progress.internals.writeStreamEvent(event.slice(), (d) => chunks.push(d));
319 const combined = Buffer.concat(chunks);
320 // console.debug(JSON.stringify(combined.toString("utf-8")));
321 controller.enqueue(combined);
322 controller.close();
323 using reader = new stream.BufferedReader(readable.getReader());
324 const decoded = await progress.internals.readStreamEvent(reader);
325 assert.deepEqual(cleanProgressEvent(decoded), cleanProgressEvent(event));
326 }
327
328 await roundTrip([
329 -1,
330 {
331 k: 1 as EncodedKey,
332 t: "job",
333 [progress.internals.kNode]: fakeNode,
334 },
335 [1],
336 ]);
337 await roundTrip([
338 0,
339 {
340 k: 1 as EncodedKey,
341 t: "job",
342 [progress.internals.kNode]: fakeNode,
343 },
344 {
345 k: 2 as EncodedKey,
346 t: "deeply nested",
347 c: [1 as EncodedKey],
348 [progress.internals.kNode]: fakeNode,
349 },
350 {
351 k: 3 as EncodedKey,
352 t: "other task",
353 c: [2 as EncodedKey],
354 [progress.internals.kNode]: fakeNode,
355 },
356 {
357 k: 4 as EncodedKey,
358 v: 4,
359 e: 7,
360 t: "purring",
361 E: 1768210855277.92,
362 [progress.internals.kNode]: fakeNode,
363 },
364 {
365 k: 5 as EncodedKey,
366 v: 2,
367 e: 7,
368 t: "meowing",
369 E: 1768210857407,
370 V: "28.6%",
371 [progress.internals.kNode]: fakeNode,
372 },
373 {
374 k: 6 as EncodedKey,
375 t: "subtask 1t07l",
376 [progress.internals.kNode]: fakeNode,
377 },
378 {
379 k: 7 as EncodedKey,
380 t: "subtask 42k1d",
381 c: [3 as EncodedKey],
382 [progress.internals.kNode]: fakeNode,
383 },
384 {
385 k: 8 as EncodedKey,
386 t: "subtask 4kdjg",
387 c: [5 as EncodedKey, 4 as EncodedKey],
388 [progress.internals.kNode]: fakeNode,
389 },
390 {
391 k: 9 as EncodedKey,
392 e: 30,
393 t: "progress node",
394 c: [8, 7, 6] as EncodedKey[],
395 [progress.internals.kNode]: fakeNode,
396 },
397 [9],
398 ]);
399
400 // log messages: a non-info level without scope/stack/custom previously
401 // desynced the whole stream (the flag tests used `&&` instead of `&`),
402 // and the message/stack-frame loops never decremented their counters
403 await roundTrip([
404 0,
405 {
406 k: 1 as EncodedKey,
407 t: "encode av1",
408 l: [
409 { level: "warn", text: "deprecated pixel format", time: 1768210850000 },
410 { level: "info", text: "hello", time: 1768210851000 },
411 {
412 level: "error",
413 text: "scoped + stacked",
414 time: 1768210852000,
415 scope: "ffmpeg",
416 stack: [{ fn: "spawn", file: "ffmpeg.ts", line: 12, col: 3 }],
417 },
418 ],
419 [progress.internals.kNode]: fakeNode,
420 },
421 [1],
422 ]);
423});
424
425function cleanProgressEvent(event: progress.StreamEvent) {
426 return event.map((x) =>
427 typeof x === "object" && !Array.isArray(x)
428 ? testing.removeUndefinedKeys({
429 ...x,
430 l: x.l?.map((m) => testing.removeUndefinedKeys({ ...m })),
431 E: undefined,
432 p: undefined,
433 s: undefined,
434 h: undefined,
435 })
436 : x
437 );
438}
439
440type EncodedKey = typeof progress.internals.EncodedKey;
441
442import assert from "node:assert/strict";
443import { describe, test, vi } from "vitest";
444import { UNWRAP } from "./assert.ts";
445import * as async from "./async.ts";
446import * as progress from "./progress.ts";
447import * as stream from "./stream.ts";
448import * as ansi from "./string/ansi.ts";
449import * as testing from "./testing.ts";