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// ## html
313test("formatHtml draws visible nodes into a declarative shadow root", () => {
314 const root = new progress.Root();
315 const build = root.start("build <site>");
316 build.start("secret", { hidden: true });
317 root.start("empty group", { passive: true });
318
319 const html = progress.formatHtml(root.active);
320 assert.match(html, /^<template shadowrootmode="open"><style>[^]*<\/style><ol class=tree role=list>/);
321 assert.match(html, /<\/ol><\/template>$/);
322 assert(html.includes(">build &lt;site&gt;</span>"));
323 assert(!html.includes("secret"));
324 assert(!html.includes("empty group"));
325});
326
327test("bars carry their value, busy rings do not", () => {
328 const root = new progress.Root();
329 root.start("download", { total: 200, value: 50 });
330 root.start("connect");
331 root.start("done", { total: 3, value: 3 });
332
333 const [download, connect, done] = items(progress.formatHtml(root.active));
334 assert.match(download!, /^<li class="bar">/);
335 assert.match(
336 download!,
337 /role=progressbar aria-labelledby=(n\d+) aria-valuenow="50" aria-valuemax="200" style="--p:0.25">/,
338 );
339 assert.match(download!, /<span class=label id=n\d+>download<\/span>/);
340 assert.match(connect!, /^<li class="spin">/);
341 assert(!connect!.includes("aria-valuenow"));
342 // a full bar stops its glow
343 assert.match(done!, /^<li class="bar full">/);
344});
345
346test("counts split at the slash, so the total can be styled apart", () => {
347 const root = new progress.Root();
348 root.start("count", { total: 48, value: 31 });
349 root.start("unstarted", { total: 48 });
350 root.start("no total", { total: 48, value: 3, showTotal: false });
351 root.start("bytes", { units: "bytes", total: 2_370_000_000, value: 1_020_000_000 });
352 root.start("percent", { units: "percent", total: 7, value: 2 });
353 root.start("custom", { value: 42, valueFormatter: (value) => `${value} fps` });
354
355 assert.deepEqual(items(progress.formatHtml(root.active)).map(count), [
356 "<b>31</b><span>48</span>",
357 "<b></b><span></span>",
358 "<b>3</b><span></span>",
359 "<b>1.02GB</b><span>2.37GB</span>",
360 "<b>28.6%</b><span></span>",
361 "<b>42 fps</b><span></span>",
362 ]);
363});
364
365test("estimates count down, and only the latest in a subtree shows", () => {
366 vi.useFakeTimers({ now: 1_000_000_000 });
367 try {
368 const root = new progress.Root();
369 const parent = root.start("parent", { estimateCompletion: false });
370 parent.estimatedTime = Date.now() + 30_000;
371 const child = parent.start("child", { estimateCompletion: false });
372 child.estimatedTime = Date.now() + 124_000;
373
374 const [parentHtml, childHtml] = items(progress.formatHtml(root.active));
375 assert.match(parentHtml!, /<span class=eta><\/span>/);
376 assert.match(childHtml!, /<span class=eta>2m 4s left<\/span>/);
377 } finally {
378 vi.useRealTimers();
379 }
380});
381
382test("logs show their last three lines", () => {
383 const root = new progress.Root();
384 const node = root.start("job");
385 node.log.info("first");
386 node.log.write("par");
387 node.log.write("tial\n");
388 node.log.warn("careful");
389 node.log.error("broke\n at frame");
390
391 assert.equal(
392 logs(progress.formatHtml(root.active)),
393 "<div class=warn>careful</div><div class=error>broke</div><div class=more> at frame</div>",
394 );
395});
396
397test("partial writes join into lines of their own", () => {
398 const root = new progress.Root();
399 const node = root.start("job");
400 node.log.info("hello");
401 node.log.write("one ");
402 node.log.write("two\nthree");
403 node.log.scoped("db").info("<connected>");
404
405 assert.equal(
406 logs(progress.formatHtml(root.active)),
407 "<div class=raw>one two</div><div class=raw>three</div>"
408 + "<div class=info><span class=scope>db</span> &lt;connected&gt;</div>",
409 );
410});
411
412test("ansi styles become spans, and carry across lines of a message", () => {
413 const root = new progress.Root();
414 const node = root.start("job");
415 node.log.write(`${ansi.fgGreen}ok${ansi.fgReset} \x1b[1;38;5;196mhot\nstill hot${ansi.reset}\x1b[2K`);
416
417 assert.equal(
418 logs(progress.formatHtml(root.active)),
419 "<div class=raw><span style=\"color:var(--ansi2);\">ok</span> "
420 + "<span style=\"color:rgb(255 0 0);font-weight:bold;\">hot</span></div>"
421 + "<div class=raw><span style=\"color:rgb(255 0 0);font-weight:bold;\">still hot</span></div>",
422 );
423});
424
425/** the markup of each drawn node, in order */
426function items(html: string) {
427 return html.split("<li ").slice(1).map((item) => "<li " + item);
428}
429
430function count(item: string) {
431 return UNWRAP(/<span class=count>(.*?)<\/span><span class=eta>/.exec(item))[1];
432}
433
434function logs(html: string) {
435 return UNWRAP(/<div class=logs>(.*?)<\/div><\/li>/.exec(html))[1];
436}
437
438// ## internals
439test("event encoding round trip cases", async () => {
440 const fakeNode = progress.nullNode;
441 async function roundTrip(event: progress.StreamEvent) {
442 const [readable, controller] = stream.readWritePair();
443 const chunks: Uint8Array[] = [];
444 progress.internals.writeStreamEvent(event.slice(), (d) => chunks.push(d));
445 const combined = Buffer.concat(chunks);
446 // console.debug(JSON.stringify(combined.toString("utf-8")));
447 controller.enqueue(combined);
448 controller.close();
449 using reader = new stream.BufferedReader(readable.getReader());
450 const decoded = await progress.internals.readStreamEvent(reader);
451 assert.deepEqual(cleanProgressEvent(decoded), cleanProgressEvent(event));
452 }
453
454 await roundTrip([
455 -1,
456 {
457 k: 1 as EncodedKey,
458 t: "job",
459 [progress.internals.kNode]: fakeNode,
460 },
461 [1],
462 ]);
463 await roundTrip([
464 0,
465 {
466 k: 1 as EncodedKey,
467 t: "job",
468 [progress.internals.kNode]: fakeNode,
469 },
470 {
471 k: 2 as EncodedKey,
472 t: "deeply nested",
473 c: [1 as EncodedKey],
474 [progress.internals.kNode]: fakeNode,
475 },
476 {
477 k: 3 as EncodedKey,
478 t: "other task",
479 c: [2 as EncodedKey],
480 [progress.internals.kNode]: fakeNode,
481 },
482 {
483 k: 4 as EncodedKey,
484 v: 4,
485 e: 7,
486 t: "purring",
487 E: 1768210855277.92,
488 [progress.internals.kNode]: fakeNode,
489 },
490 {
491 k: 5 as EncodedKey,
492 v: 2,
493 e: 7,
494 t: "meowing",
495 E: 1768210857407,
496 V: "28.6%",
497 [progress.internals.kNode]: fakeNode,
498 },
499 {
500 k: 6 as EncodedKey,
501 t: "subtask 1t07l",
502 [progress.internals.kNode]: fakeNode,
503 },
504 {
505 k: 7 as EncodedKey,
506 t: "subtask 42k1d",
507 c: [3 as EncodedKey],
508 [progress.internals.kNode]: fakeNode,
509 },
510 {
511 k: 8 as EncodedKey,
512 t: "subtask 4kdjg",
513 c: [5 as EncodedKey, 4 as EncodedKey],
514 [progress.internals.kNode]: fakeNode,
515 },
516 {
517 k: 9 as EncodedKey,
518 e: 30,
519 t: "progress node",
520 c: [8, 7, 6] as EncodedKey[],
521 [progress.internals.kNode]: fakeNode,
522 },
523 [9],
524 ]);
525
526 // log messages: a non-info level without scope/stack/custom previously
527 // desynced the whole stream (the flag tests used `&&` instead of `&`),
528 // and the message/stack-frame loops never decremented their counters
529 await roundTrip([
530 0,
531 {
532 k: 1 as EncodedKey,
533 t: "encode av1",
534 l: [
535 { level: "warn", text: "deprecated pixel format", time: 1768210850000 },
536 { level: "info", text: "hello", time: 1768210851000 },
537 { level: "info", text: "partial ", time: 1768210851500, newline: false },
538 {
539 level: "error",
540 text: "scoped + stacked",
541 time: 1768210852000,
542 scope: "ffmpeg",
543 stack: [{ fn: "spawn", file: "ffmpeg.ts", line: 12, col: 3 }],
544 },
545 ],
546 [progress.internals.kNode]: fakeNode,
547 },
548 [1],
549 ]);
550});
551
552function cleanProgressEvent(event: progress.StreamEvent) {
553 return event.map((x) =>
554 typeof x === "object" && !Array.isArray(x)
555 ? testing.removeUndefinedKeys({
556 ...x,
557 l: x.l?.map((m) => testing.removeUndefinedKeys({ ...m })),
558 E: undefined,
559 p: undefined,
560 s: undefined,
561 h: undefined,
562 })
563 : x
564 );
565}
566
567type EncodedKey = typeof progress.internals.EncodedKey;
568
569import assert from "node:assert/strict";
570import { describe, test, vi } from "vitest";
571import { UNWRAP } from "./assert.ts";
572import * as async from "./async.ts";
573import * as progress from "./progress.ts";
574import * as stream from "./stream.ts";
575import * as ansi from "./string/ansi.ts";
576import * as testing from "./testing.ts";