| author | |
| committer | |
| log | a74b35e546be0d1d10c1da28e8daedec77ad69b4 |
| tree | 47e2195337d9cecf2543495c156dbffb4ef04cf3 |
| parent | e577adcfbe4eb4c80434c05b23beee441458d5a4 |
| signature | Commit is signed but in an unrecognized format. |
also resolves #403 files changed, 391 insertions(+), 194 deletions(-)
lib/log.test.ts+72-150| ... | ... | @@ -1,164 +1,86 @@ |
| 1 | import { ASSERT, UNWRAP } from "lib/assert.ts"; | |
| 2 | import { test } from "node:test"; | |
| 3 | import * as headless from "lib/log/headless.ts"; | |
| 4 | import * as log from "lib/log.ts"; | |
| 5 | ||
| 6 | log satisfies log.Scope; // the namespace import is a valid Scope | |
| 7 | ||
| 8 | test("writeLine buffers", () => { | |
| 9 | using host = new TestWidgetHost(); | |
| 10 | host.writeLine("hello world"); | |
| 11 | host.writeLine("and so on"); | |
| 12 | host.expectFrame(0, { | |
| 13 | stdout: "hello world\nand so on\n", | |
| 14 | }); | |
| 15 | host.writeLine("more logs"); | |
| 16 | host.expectFrame(0, { | |
| 17 | stdout: "more logs\n", | |
| 1 | // the namespace import is a valid Scope | |
| 2 | log satisfies log.Scope; | |
| 3 | ||
| 4 | // these tests for `startWidget` are built on a custom widget host | |
| 5 | // implementation that buffers all bytes in memory. it is a great | |
| 6 | // example of how modular the entire system is. | |
| 7 | describe("log widgets", () => { | |
| 8 | test("writeLine buffers", () => { | |
| 9 | using host = new testing.MockScreen(); | |
| 10 | host.writeLine("hello world"); | |
| 11 | host.writeLine("and so on"); | |
| 12 | host.expectFrame(0, { | |
| 13 | stdout: "hello world\nand so on\n", | |
| 14 | }); | |
| 15 | host.writeLine("more logs"); | |
| 16 | host.expectFrame(0, { | |
| 17 | stdout: "more logs\n", | |
| 18 | }); | |
| 18 | 19 | }); |
| 19 | }); | |
| 20 | 20 | |
| 21 | test("widget with interweaving logs", () => { | |
| 22 | const host = new TestWidgetHost(); | |
| 21 | test("widget with interweaving logs", () => { | |
| 22 | using host = new testing.MockScreen(); | |
| 23 | 23 | |
| 24 | const _ = host.startWidget({ | |
| 25 | format: (now) => `line one ${now}\nline two`, | |
| 26 | }); | |
| 24 | using _ = host.startWidget({ | |
| 25 | format: (now) => `line one ${now}\nline two`, | |
| 26 | }); | |
| 27 | 27 | |
| 28 | host.expectFrame(0, { | |
| 29 | stderr: sync([ | |
| 30 | ansi.startOfLine, | |
| 31 | "line one 0\nline two\n", | |
| 32 | ]), | |
| 33 | }); | |
| 28 | host.expectFrame(0, { | |
| 29 | stderr: testing.MockScreen.sync([ | |
| 30 | ansi.startOfLine, | |
| 31 | "line one 0\nline two\n", | |
| 32 | ]), | |
| 33 | }); | |
| 34 | 34 | |
| 35 | host.writeLine("info: line of log"); | |
| 36 | host.expectFrame(0, { | |
| 37 | out: sync([ | |
| 38 | ansi.startOfLine, | |
| 39 | ansi.cursorUp(2), | |
| 40 | ansi.clearFullLine, | |
| 41 | "info: line of log\n", | |
| 42 | "line one 0\nline two\n", | |
| 43 | ]), | |
| 44 | }); | |
| 35 | host.writeLine("info: line of log"); | |
| 36 | host.expectFrame(0, { | |
| 37 | merged: testing.MockScreen.sync([ | |
| 38 | ansi.startOfLine, | |
| 39 | ansi.cursorUp(2), | |
| 40 | ansi.clearFullLine, | |
| 41 | "info: line of log\n", | |
| 42 | "line one 0\nline two\n", | |
| 43 | ]), | |
| 44 | }); | |
| 45 | 45 | |
| 46 | host.writeLine("warn: line of log"); | |
| 47 | host.writeLine("error: line of log"); | |
| 48 | host.expectFrame(0, { | |
| 49 | out: sync([ | |
| 50 | ansi.startOfLine, | |
| 51 | ansi.cursorUp(1), | |
| 52 | ansi.clearFullLine, | |
| 53 | ansi.cursorUp(1), | |
| 54 | ansi.clearFullLine, | |
| 55 | "warn: line of log\n", | |
| 56 | "error: line of log\n", | |
| 57 | "line one 0\nline two\n", | |
| 58 | ]), | |
| 46 | host.writeLine("warn: line of log"); | |
| 47 | host.writeLine("error: line of log"); | |
| 48 | host.expectFrame(0, { | |
| 49 | merged: testing.MockScreen.sync([ | |
| 50 | ansi.startOfLine, | |
| 51 | ansi.cursorUp(1), | |
| 52 | ansi.clearFullLine, | |
| 53 | ansi.cursorUp(1), | |
| 54 | ansi.clearFullLine, | |
| 55 | "warn: line of log\n", | |
| 56 | "error: line of log\n", | |
| 57 | "line one 0\nline two\n", | |
| 58 | ]), | |
| 59 | }); | |
| 59 | 60 | }); |
| 60 | }); | |
| 61 | ||
| 62 | function sync(text: string[]) { | |
| 63 | return ansi.syncStart + text.join("") + ansi.syncEnd; | |
| 64 | } | |
| 65 | ||
| 66 | class TestWidgetHost implements headless.WidgetHost { | |
| 67 | columns = 80; | |
| 68 | rows = 33; | |
| 69 | ||
| 70 | now = 0; | |
| 71 | ||
| 72 | stdout: string = ""; | |
| 73 | stderr: string = ""; | |
| 74 | out: string = ""; | |
| 75 | writeCalls = 0; | |
| 76 | 61 | |
| 77 | waitDuration = 0; | |
| 78 | waiter: (() => void) | null = null; | |
| 62 | test("widget with logs in same frame", () => { | |
| 63 | using host = new testing.MockScreen(); | |
| 79 | 64 | |
| 80 | writeLine: headless.WidgetHost["writeLine"]; | |
| 81 | getDrawLock: headless.WidgetHost["getDrawLock"]; | |
| 82 | startWidget: headless.WidgetHost["startWidget"]; | |
| 83 | cancel: headless.WidgetHost["cancel"]; | |
| 65 | host.writeLine("log line 1"); | |
| 66 | host.writeLine("log line 2"); | |
| 84 | 67 | |
| 85 | constructor() { | |
| 86 | const host = headless.widgetHost({ | |
| 87 | writeInteractive: (text) => { | |
| 88 | this.stderr += text; | |
| 89 | this.out += text; | |
| 90 | this.writeCalls += 1; | |
| 91 | }, | |
| 92 | writeOutput: (text) => { | |
| 93 | this.stdout += text; | |
| 94 | this.out += text; | |
| 95 | this.writeCalls += 1; | |
| 96 | }, | |
| 97 | now: () => { | |
| 98 | return this.now; | |
| 99 | }, | |
| 100 | wait: (ms, cb) => { | |
| 101 | this.waitDuration = ms; | |
| 102 | this.waiter = cb; | |
| 103 | return () => this.waiter = null; | |
| 104 | }, | |
| 105 | getSize: () => { | |
| 106 | return this; | |
| 107 | }, | |
| 68 | using _ = host.startWidget({ | |
| 69 | format: (now) => `widget line one ${now}\nwidget line two`, | |
| 108 | 70 | }); |
| 109 | this.writeLine = host.writeLine; | |
| 110 | this.getDrawLock = host.getDrawLock; | |
| 111 | this.startWidget = host.startWidget; | |
| 112 | this.cancel = host.cancel; | |
| 113 | } | |
| 114 | 71 | |
| 115 | expectFrame(ms: number, { stdout, stderr, out }: { | |
| 116 | stdout?: string; | |
| 117 | stderr?: string; | |
| 118 | out?: string; | |
| 119 | }) { | |
| 120 | const { waitDuration } = this; | |
| 121 | ASSERT( | |
| 122 | ms === waitDuration, | |
| 123 | `expected ${ms}ms to pass, only ${waitDuration}`, | |
| 124 | ); | |
| 125 | ||
| 126 | const callback = UNWRAP(this.waiter, "no pending frame"); | |
| 127 | this.waiter = null; | |
| 128 | this.now += this.waitDuration; | |
| 129 | callback(); | |
| 130 | ||
| 131 | ASSERT( | |
| 132 | out == null || this.out === out, | |
| 133 | () => | |
| 134 | `interweved out does not match\n` + | |
| 135 | `expected: ${ansi.debugAnsi(out ?? "")}\n` + | |
| 136 | `actual: ${ansi.debugAnsi(this.out)}\n`, | |
| 137 | ); | |
| 138 | ASSERT( | |
| 139 | stdout == null || this.stdout === stdout, | |
| 140 | () => | |
| 141 | `standard out does not match\n` + | |
| 142 | `expected: ${ansi.debugAnsi(stdout ?? "")}\n` + | |
| 143 | `actual: ${ansi.debugAnsi(this.stdout)}\n`, | |
| 144 | ); | |
| 145 | ASSERT( | |
| 146 | stderr == null || this.stderr === stderr, | |
| 147 | () => | |
| 148 | `interactive out does not match\n` + | |
| 149 | `expected: ${ansi.debugAnsi(stderr ?? "")}\n` + | |
| 150 | `actual: ${ansi.debugAnsi(this.stderr)}\n`, | |
| 151 | ); | |
| 152 | this.stdout = ""; | |
| 153 | this.stderr = ""; | |
| 154 | this.out = ""; | |
| 155 | } | |
| 156 | ||
| 157 | [Symbol.dispose]() { | |
| 158 | ASSERT(!this.waiter, "there is a pending write!"); | |
| 159 | ASSERT(!this.stdout, "unread standard out" + this.stdout); | |
| 160 | ASSERT(!this.stderr, "unread interactive out" + this.stderr); | |
| 161 | } | |
| 162 | } | |
| 72 | host.expectFrame(0, { | |
| 73 | stdout: "log line 1\nlog line 2\n", | |
| 74 | merged: testing.MockScreen.sync([ | |
| 75 | "log line 1\n", | |
| 76 | "log line 2\n", | |
| 77 | "widget line one 0\nwidget line two\n", | |
| 78 | ]), | |
| 79 | }); | |
| 80 | }); | |
| 81 | }); | |
| 163 | 82 | |
| 83 | import { describe, test } from "node:test"; | |
| 164 | 84 | import * as ansi from "./string/ansi.ts"; |
| 85 | import * as log from "./log.ts"; | |
| 86 | import * as testing from "./testing.ts"; |
lib/log.ts+80-44| ... | ... | @@ -34,7 +34,8 @@ |
| 34 | 34 | * - otherwise, logs are surfaced using the global `console` API |
| 35 | 35 | * |
| 36 | 36 | * custom log integrations can be built on top of this module by calling |
| 37 | * `log.tee()` to duplicate all messages elsewhere. for example, a project may set up | |
| 37 | * `log.tee()` to duplicate all messages elsewhere. for example, a project may | |
| 38 | * configure logs to upload to a telemetry service. | |
| 38 | 39 | * |
| 39 | 40 | * @module |
| 40 | 41 | */ |
| ... | ... | @@ -123,10 +124,29 @@ export function log(...args: unknown[]) { |
| 123 | 124 | export function debug(...args: unknown[]) { |
| 124 | 125 | globalLog.debug(...args); |
| 125 | 126 | } |
| 126 | ||
| 127 | /** create a named logging scope */ | |
| 127 | 128 | export function scoped(name: string): Scope { |
| 128 | 129 | return globalLog.scoped(name); |
| 129 | 130 | } |
| 131 | /** redirect all log messages to another writer */ | |
| 132 | export function tee(destination: DispatchFunction): ts.Dispose { | |
| 133 | return globalLog.tee(destination); | |
| 134 | } | |
| 135 | /** replace the default writer */ | |
| 136 | export function replaceGlobalDestination(destination: DispatchFunction) { | |
| 137 | globalOutputFunction = destination; | |
| 138 | } | |
| 139 | ||
| 140 | /** replace the default formatter */ | |
| 141 | export function replaceGlobalFormatFunction( | |
| 142 | format: (msg: Message, colors: boolean) => string, | |
| 143 | ) { | |
| 144 | globalMessageFormatFunction = format; | |
| 145 | } | |
| 146 | ||
| 147 | export function formatMessage(msg: Message, colors: boolean) { | |
| 148 | return globalMessageFormatFunction(msg, colors); | |
| 149 | } | |
| 130 | 150 | |
| 131 | 151 | /** |
| 132 | 152 | * a widget is an interactive display that persists at the end |
| ... | ... | @@ -169,7 +189,7 @@ export function headlessScope(dispatch: DispatchFunction): Scope { |
| 169 | 189 | return new Scope(dispatch); |
| 170 | 190 | } |
| 171 | 191 | |
| 172 | /** See {@linkcode startWidget} */ | |
| 192 | /** see {@linkcode startWidget} */ | |
| 173 | 193 | export interface Widget { |
| 174 | 194 | /** |
| 175 | 195 | * return the widget's text. return null to detach the widget. |
| ... | ... | @@ -190,17 +210,17 @@ export interface Widget { |
| 190 | 210 | |
| 191 | 211 | /** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */ |
| 192 | 212 | export interface HeadlessWidgetEnv { |
| 193 | /** Recieves ANSI escape sequences for interactive data. */ | |
| 213 | /** recieves ANSI escape sequences for interactive data */ | |
| 194 | 214 | writeInteractive(text: string): void; |
| 195 | /** Recieves log content (from `writeLine`). */ | |
| 215 | /** recieves log content (from `writeLine`) */ | |
| 196 | 216 | writeOutput(text: string): void; |
| 197 | /** Monotonic. */ | |
| 217 | /** monotonic milliseconds */ | |
| 198 | 218 | now(): ReturnType<typeof performance.now>; |
| 199 | /** 0ms indicates "one frame". */ | |
| 200 | wait(ms: number, cb: () => void): () => void; | |
| 201 | /** Called often. */ | |
| 219 | /** after resolving, `now()` should have increased by the delay time */ | |
| 220 | delay: typeof async.delay; | |
| 221 | /** called often. */ | |
| 202 | 222 | getSize(): { columns: number; rows: number }; |
| 203 | /** Called to enable input events */ | |
| 223 | /** called to enable input events */ | |
| 204 | 224 | onInput?(write: (bytes: Uint8Array | string) => void): () => void; |
| 205 | 225 | } |
| 206 | 226 | |
| ... | ... | @@ -214,8 +234,13 @@ export interface HeadlessWidgetHost { |
| 214 | 234 | startWidget(widget: Widget): ts.Dispose; |
| 215 | 235 | /** stop all widgets and remove all timers. */ |
| 216 | 236 | cancel(): void; |
| 237 | /** generic delay function */ | |
| 238 | delay?: typeof async.delay; | |
| 239 | /** generic now function */ | |
| 240 | now?: typeof performance.now; | |
| 217 | 241 | } |
| 218 | 242 | |
| 243 | /** @internal state */ | |
| 219 | 244 | interface WidgetState { |
| 220 | 245 | frameTime: number; |
| 221 | 246 | next: number; |
| ... | ... | @@ -238,10 +263,9 @@ interface WidgetState { |
| 238 | 263 | * Points 2 and 3 are used for terminals that are slow or do not support sync. |
| 239 | 264 | */ |
| 240 | 265 | export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 241 | const { writeOutput, writeInteractive, now, wait, getSize } = env; | |
| 242 | type Cancel = ReturnType<typeof wait>; | |
| 266 | const { writeOutput, writeInteractive, now, delay, getSize } = env; | |
| 243 | 267 | |
| 244 | let timer: Cancel | null = null; | |
| 268 | let timer: async.Cancelable<void> | null = null; | |
| 245 | 269 | |
| 246 | 270 | let locks = 0; |
| 247 | 271 | let redrawTime = 0; |
| ... | ... | @@ -313,20 +337,22 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 313 | 337 | |
| 314 | 338 | if (buffer) { |
| 315 | 339 | // do not perform diffing since the entire screen is moving down |
| 316 | // TODO: should i handle wrapping? | |
| 340 | // TODO: should lib/log handle wrapping? | |
| 317 | 341 | let clearLinesTop = Math.min( |
| 318 | 342 | lines.length, |
| 319 | 343 | string.countNewlines(buffer), |
| 320 | 344 | ); |
| 321 | 345 | const oldLines = lines.slice(clearLinesTop); |
| 322 | 346 | writeInteractive( |
| 323 | ansi.syncStart + ansi.startOfLine + | |
| 324 | // skip up the shared widget space | |
| 325 | ansi.cursorUp(lines.length - clearLinesTop + 1) + | |
| 326 | // clear the lines to contain `buffer` | |
| 327 | ansi.clearFullLine + | |
| 328 | (ansi.cursorUp(1) + ansi.clearFullLine) | |
| 329 | .repeat(clearLinesTop - 1), | |
| 347 | ansi.syncStart + (clearLinesTop > 0 | |
| 348 | ? ansi.startOfLine + | |
| 349 | // skip up the shared widget space | |
| 350 | ansi.cursorUp(lines.length - clearLinesTop + 1) + | |
| 351 | // clear the lines to contain `buffer` | |
| 352 | ansi.clearFullLine + | |
| 353 | (ansi.cursorUp(1) + ansi.clearFullLine) | |
| 354 | .repeat(clearLinesTop - 1) | |
| 355 | : ""), | |
| 330 | 356 | ); |
| 331 | 357 | // then write output lines on standard out |
| 332 | 358 | writeOutput(buffer); |
| ... | ... | @@ -385,15 +411,16 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 385 | 411 | const newRedrawTime = now() + ms; |
| 386 | 412 | if (timer) { |
| 387 | 413 | if (redrawTime < newRedrawTime) return; |
| 388 | timer(); // cancel previous | |
| 414 | timer.cancel(); // cancel previous | |
| 389 | 415 | timer = null; |
| 390 | 416 | } |
| 391 | 417 | redrawTime = newRedrawTime + 1; |
| 392 | timer = wait(ms, redrawCallback); | |
| 418 | timer = delay(ms); | |
| 419 | timer.then(redrawCallback); | |
| 393 | 420 | } |
| 394 | 421 | |
| 395 | 422 | function flushAndClear() { |
| 396 | timer?.(); | |
| 423 | timer?.cancel(); | |
| 397 | 424 | timer = null; |
| 398 | 425 | if (lines.length > 0) { |
| 399 | 426 | // clear widgets |
| ... | ... | @@ -442,6 +469,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 442 | 469 | cancel() { |
| 443 | 470 | flushAndClear(); |
| 444 | 471 | }, |
| 472 | delay, | |
| 473 | now, | |
| 445 | 474 | }; |
| 446 | 475 | } |
| 447 | 476 | |
| ... | ... | @@ -469,6 +498,7 @@ let formatLine = /* @__PURE__ */ (() => { |
| 469 | 498 | let withinDispatch = false; |
| 470 | 499 | let withinStackCapture = false; |
| 471 | 500 | |
| 501 | /** this class is an implementation detail */ | |
| 472 | 502 | const Scope = class Scope implements Scope { |
| 473 | 503 | name: string | undefined; |
| 474 | 504 | // TODO: this abstraction implementation has low performance. making every |
| ... | ... | @@ -544,7 +574,7 @@ const Scope = class Scope implements Scope { |
| 544 | 574 | }; |
| 545 | 575 | |
| 546 | 576 | const globalWidgetHost = /* @__PURE__ */ (() => { |
| 547 | // TODO; write this in a tree-shakable manner | |
| 577 | // TODO; write this in a more tree-shakable manner | |
| 548 | 578 | const { process } = node; |
| 549 | 579 | if (!process || !process.stderr.isTTY) { |
| 550 | 580 | // return a no-op |
| ... | ... | @@ -570,10 +600,7 @@ const globalWidgetHost = /* @__PURE__ */ (() => { |
| 570 | 600 | writeOutput: (string) => process.stdout.write(string), |
| 571 | 601 | writeInteractive: (string) => process.stderr.write(string), |
| 572 | 602 | now: () => performance.now(), |
| 573 | wait: (ms, cb) => { | |
| 574 | const id = setTimeout(cb, ms); | |
| 575 | return () => clearTimeout(id); | |
| 576 | }, | |
| 603 | delay: async.delay, | |
| 577 | 604 | getSize: () => process.stderr, |
| 578 | 605 | }); |
| 579 | 606 | process.addListener("beforeExit", () => widget.cancel()); |
| ... | ... | @@ -603,23 +630,29 @@ const levelToAnsi: Record<Message["level"], string> = { |
| 603 | 630 | debug: `${ansi.dim}dbg`, |
| 604 | 631 | }; |
| 605 | 632 | |
| 633 | let globalMessageFormatFunction: MessageFormatFunction = ( | |
| 634 | { level, scope, text }, | |
| 635 | colors, | |
| 636 | ) => { | |
| 637 | if (!text) return ""; | |
| 638 | const prefix = colors | |
| 639 | // colorful | |
| 640 | ? `${levelToAnsi[level]}${ | |
| 641 | scope ? `(${scope})` : "" | |
| 642 | }${ansi.fgReset}${ansi.dim}:${ansi.reset} ` | |
| 643 | // colorless | |
| 644 | : scope | |
| 645 | ? `${level}(${scope}): ` | |
| 646 | : `${level}: `; | |
| 647 | return prefix + text; | |
| 648 | }; | |
| 606 | 649 | let globalOutputFunction!: DispatchFunction; |
| 607 | 650 | const globalLog = /* @__PURE__ */ (() => { |
| 608 | 651 | const colors = node.process?.stderr.isTTY ?? false; |
| 609 | 652 | globalOutputFunction = node.process |
| 610 | 653 | // In Node.js, coordinate with the widget host |
| 611 | ? ({ level, scope, text }) => { | |
| 612 | if (!text) return globalWidgetHost.writeLine(""); | |
| 613 | const prefix = colors | |
| 614 | // colorful | |
| 615 | ? `${levelToAnsi[level]}${ | |
| 616 | scope ? `(${scope})` : "" | |
| 617 | }${ansi.fgReset}${ansi.dim}:${ansi.reset} ` | |
| 618 | // colorless | |
| 619 | : scope | |
| 620 | ? `${level}(${scope}): ` | |
| 621 | : `${level}: `; | |
| 622 | globalWidgetHost.writeLine(prefix + text); | |
| 654 | ? (message) => { | |
| 655 | globalWidgetHost.writeLine(globalMessageFormatFunction(message, colors)); | |
| 623 | 656 | } |
| 624 | 657 | // Otherwise, forward to `console` |
| 625 | 658 | : (m) => { |
| ... | ... | @@ -635,11 +668,14 @@ const globalLog = /* @__PURE__ */ (() => { |
| 635 | 668 | return new Scope(globalOutputFunction); |
| 636 | 669 | })(); |
| 637 | 670 | |
| 638 | export interface DispatchFunction { | |
| 639 | (message: Message): void; | |
| 640 | } | |
| 671 | export type DispatchFunction = (message: Message) => void; | |
| 672 | export type MessageFormatFunction = ( | |
| 673 | message: Message, | |
| 674 | colors: boolean, | |
| 675 | ) => string; | |
| 641 | 676 | |
| 642 | 677 | import * as ansi from "./string/ansi.ts"; |
| 678 | import * as async from "./async.ts"; | |
| 643 | 679 | import * as node from "./node.ts"; |
| 644 | 680 | import * as stack from "./log/stack.ts"; |
| 645 | 681 | import * as string from "./string.ts"; |
lib/testing.ts created+239| ... | ... | @@ -0,0 +1,239 @@ |
| 1 | /** | |
| 2 | * @module contains some utilities for writing tests. many are only useful for | |
| 3 | * testing against other library modules. | |
| 4 | */ | |
| 5 | ||
| 6 | /** | |
| 7 | * implements `Promise` but reactions are emitted instantly. | |
| 8 | * | |
| 9 | * this is used for some tests where the promise interface is used in a | |
| 10 | * synchronous way. an example of this is the log and progress tests use this | |
| 11 | * to satisfy the async interface function for `delay` but using code that runs | |
| 12 | * fully synchronously. | |
| 13 | */ | |
| 14 | export class SyncPromise<T> implements Promise<T> { | |
| 15 | [Symbol.toStringTag] = "SyncPromise"; | |
| 16 | #status: "pending" | "resolved" | "rejected" = "pending"; | |
| 17 | #value: unknown = null; | |
| 18 | #resolve: Array<(value: T) => void> = []; | |
| 19 | #reject: Array<(error: unknown) => void> = []; | |
| 20 | #finally: Array<() => void> = []; | |
| 21 | ||
| 22 | constructor( | |
| 23 | init: ( | |
| 24 | resolve: (value: T) => void, | |
| 25 | reject: (error: unknown) => void, | |
| 26 | ) => void, | |
| 27 | ) { | |
| 28 | init( | |
| 29 | (value) => { | |
| 30 | if (this.#status !== "pending") return; | |
| 31 | this.#status = "resolved"; | |
| 32 | this.#value = value; | |
| 33 | this.#resolve.forEach((cb) => cb(value)); | |
| 34 | this.#finally.forEach((cb) => cb()); | |
| 35 | }, | |
| 36 | (error) => { | |
| 37 | if (this.#status !== "pending") return; | |
| 38 | this.#status = "rejected"; | |
| 39 | this.#value = error; | |
| 40 | if (this.#reject.length === 0) throw error; | |
| 41 | this.#reject.forEach((cb) => cb(error)); | |
| 42 | this.#finally.forEach((cb) => cb()); | |
| 43 | }, | |
| 44 | ); | |
| 45 | } | |
| 46 | ||
| 47 | then<TResult1 = T, TResult2 = never>( | |
| 48 | onfulfilled?: | |
| 49 | | ((value: T) => TResult1 | PromiseLike<TResult1>) | |
| 50 | | null | |
| 51 | | undefined, | |
| 52 | onrejected?: | |
| 53 | | ((reason: any) => TResult2 | PromiseLike<TResult2>) | |
| 54 | | null | |
| 55 | | undefined, | |
| 56 | ): Promise<TResult1 | TResult2> { | |
| 57 | if (this.#status === "rejected") throw this.#value; | |
| 58 | if (this.#status === "resolved") { | |
| 59 | const result = onfulfilled | |
| 60 | ? onfulfilled(this.#value as T) | |
| 61 | : this.#value as T; | |
| 62 | return new SyncPromise((resolve) => { | |
| 63 | if (typeof result === "object" && result && "then" in result) { | |
| 64 | result.then?.(resolve); | |
| 65 | } else { | |
| 66 | resolve(result as TResult1); | |
| 67 | } | |
| 68 | }); | |
| 69 | } | |
| 70 | let resolve: (value: TResult1 | TResult2) => void; | |
| 71 | let reject: (error: unknown) => void; | |
| 72 | function react<X>( | |
| 73 | value: X, | |
| 74 | reactor: ( | |
| 75 | value: X, | |
| 76 | ) => TResult1 | TResult2 | PromiseLike<TResult1 | TResult2>, | |
| 77 | ) { | |
| 78 | try { | |
| 79 | const result = reactor(value); | |
| 80 | if (typeof result === "object" && result && "then" in result) { | |
| 81 | result.then?.(resolve); | |
| 82 | } else { | |
| 83 | resolve(result); | |
| 84 | } | |
| 85 | } catch (error) { | |
| 86 | reject(error); | |
| 87 | } | |
| 88 | } | |
| 89 | this.#resolve.push((value) => onfulfilled && react(value, onfulfilled)); | |
| 90 | if (onrejected) this.#reject.push((value) => react(value, onrejected)); | |
| 91 | return new SyncPromise((innerResolve, innerReject) => { | |
| 92 | resolve = innerResolve; | |
| 93 | reject = innerReject; | |
| 94 | }); | |
| 95 | } | |
| 96 | catch<TResult = never>( | |
| 97 | onrejected?: | |
| 98 | | ((reason: any) => TResult | PromiseLike<TResult>) | |
| 99 | | null | |
| 100 | | undefined, | |
| 101 | ): Promise<T | TResult> { | |
| 102 | return this.then((x) => x, onrejected); | |
| 103 | } | |
| 104 | finally(onfinally?: (() => void) | null | undefined): Promise<T> { | |
| 105 | if (!onfinally) return this; | |
| 106 | if (this.#status === "pending") this.#finally.push(onfinally); | |
| 107 | else onfinally(); | |
| 108 | return this; | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | /** implements a log.HeadlessWidgetHost that acts as a fake screen. */ | |
| 113 | export class MockScreen implements Disposable, log.HeadlessWidgetHost { | |
| 114 | columns = 80; | |
| 115 | rows = 33; | |
| 116 | ||
| 117 | time = 0; | |
| 118 | ||
| 119 | stdout: string = ""; | |
| 120 | stderr: string = ""; | |
| 121 | out: string = ""; | |
| 122 | writeCalls = 0; | |
| 123 | ||
| 124 | waitCalls: Array<{ | |
| 125 | duration: number; | |
| 126 | resolve: () => void; | |
| 127 | src: stack.Frame[]; | |
| 128 | }> = []; | |
| 129 | waitTime = 0; | |
| 130 | ||
| 131 | writeLine: log.HeadlessWidgetHost["writeLine"]; | |
| 132 | getDrawLock: log.HeadlessWidgetHost["getDrawLock"]; | |
| 133 | startWidget: log.HeadlessWidgetHost["startWidget"]; | |
| 134 | cancel: log.HeadlessWidgetHost["cancel"]; | |
| 135 | delay: log.HeadlessWidgetHost["delay"]; | |
| 136 | now: log.HeadlessWidgetHost["now"]; | |
| 137 | ||
| 138 | static sync(text: string[]) { | |
| 139 | return ansi.syncStart + text.join("") + ansi.syncEnd; | |
| 140 | } | |
| 141 | ||
| 142 | constructor() { | |
| 143 | const host = log.headlessWidgetHost({ | |
| 144 | writeInteractive: (text) => { | |
| 145 | this.stderr += text; | |
| 146 | this.out += text; | |
| 147 | this.writeCalls += 1; | |
| 148 | }, | |
| 149 | writeOutput: (text) => { | |
| 150 | this.stdout += text; | |
| 151 | this.out += text; | |
| 152 | this.writeCalls += 1; | |
| 153 | }, | |
| 154 | now: () => { | |
| 155 | return this.time; | |
| 156 | }, | |
| 157 | delay: (ms) => { | |
| 158 | const src = stack.capture(2); | |
| 159 | return async.makeCancelable( | |
| 160 | new SyncPromise((resolve) => { | |
| 161 | this.waitCalls.push({ | |
| 162 | duration: ms - this.waitTime, | |
| 163 | resolve, | |
| 164 | src, | |
| 165 | }); | |
| 166 | this.waitTime = ms; | |
| 167 | }), | |
| 168 | () => { | |
| 169 | throw new Error("TODO"); | |
| 170 | }, | |
| 171 | ); | |
| 172 | }, | |
| 173 | getSize: () => { | |
| 174 | return this; | |
| 175 | }, | |
| 176 | }); | |
| 177 | this.writeLine = host.writeLine; | |
| 178 | this.getDrawLock = host.getDrawLock; | |
| 179 | this.startWidget = host.startWidget; | |
| 180 | this.cancel = host.cancel; | |
| 181 | this.delay = host.delay; | |
| 182 | this.now = host.now; | |
| 183 | } | |
| 184 | ||
| 185 | expectFrame(ms: number, { stdout, stderr, merged: out }: { | |
| 186 | stdout?: string; | |
| 187 | stderr?: string; | |
| 188 | merged?: string; | |
| 189 | }) { | |
| 190 | const wait = UNWRAP(this.waitCalls.shift(), "no call to MockScreen.delay"); | |
| 191 | ASSERT( | |
| 192 | ms === wait.duration, | |
| 193 | `expected ${ms}ms to pass, got ${wait.duration}, from:\n${ | |
| 194 | wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n") | |
| 195 | }`, | |
| 196 | ); | |
| 197 | ||
| 198 | this.time += wait.duration; | |
| 199 | this.waitTime = 0; | |
| 200 | wait.resolve(); | |
| 201 | ||
| 202 | ASSERT( | |
| 203 | out == null || this.out === out, | |
| 204 | () => | |
| 205 | `interweved out does not match\n` + | |
| 206 | `expected: ${ansi.debugAnsi(out ?? "")}\n` + | |
| 207 | `actual: ${ansi.debugAnsi(this.out)}\n`, | |
| 208 | ); | |
| 209 | ASSERT( | |
| 210 | stdout == null || this.stdout === stdout, | |
| 211 | () => | |
| 212 | `standard out does not match\n` + | |
| 213 | `expected: ${ansi.debugAnsi(stdout ?? "")}\n` + | |
| 214 | `actual: ${ansi.debugAnsi(this.stdout)}\n`, | |
| 215 | ); | |
| 216 | ASSERT( | |
| 217 | stderr == null || this.stderr === stderr, | |
| 218 | () => | |
| 219 | `interactive out does not match\n` + | |
| 220 | `expected: ${ansi.debugAnsi(stderr ?? "")}\n` + | |
| 221 | `actual: ${ansi.debugAnsi(this.stderr)}\n`, | |
| 222 | ); | |
| 223 | this.stdout = ""; | |
| 224 | this.stderr = ""; | |
| 225 | this.out = ""; | |
| 226 | } | |
| 227 | ||
| 228 | [Symbol.dispose]() { | |
| 229 | ASSERT(this.waitCalls.length === 0, "there is a pending write!"); | |
| 230 | ASSERT(!this.stdout, "unread standard out" + this.stdout); | |
| 231 | ASSERT(!this.stderr, "unread interactive out" + this.stderr); | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | import * as ansi from "lib/string/ansi.ts"; | |
| 236 | import { ASSERT, UNWRAP } from "lib/assert.ts"; | |
| 237 | import * as log from "lib/log.ts"; | |
| 238 | import * as async from "lib/async.ts"; | |
| 239 | import * as stack from "lib/log/stack.ts"; |