| 1 | // a trivial example of how to use progress |
| 2 | test("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 | |
| 51 | test("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 | |
| 73 | test("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 | |
| 101 | describe("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 | |
| 213 | test("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 |
| 227 | test("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 | }); |
| 236 | test("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 | }); |
| 241 | test("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 | |
| 247 | test("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 | |
| 255 | test("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 | |
| 300 | test("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 |
| 313 | test("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 | |
| 425 | function 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 | |
| 440 | type EncodedKey = typeof progress.internals.EncodedKey; |
| 441 | |
| 442 | import assert from "node:assert/strict"; |
| 443 | import { describe, test, vi } from "vitest"; |
| 444 | import { UNWRAP } from "./assert.ts"; |
| 445 | import * as async from "./async.ts"; |
| 446 | import * as progress from "./progress.ts"; |
| 447 | import * as stream from "./stream.ts"; |
| 448 | import * as ansi from "./string/ansi.ts"; |
| 449 | import * as testing from "./testing.ts"; |