| 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,164 +1,86 @@ |
| 1 | import { ASSERT, UNWRAP } from "lib/assert.ts"; | 1 | // the namespace import is a valid Scope |
| 2 | import { test } from "node:test"; | 2 | log satisfies log.Scope; |
| 3 | import * as headless from "lib/log/headless.ts"; | 3 | |
| 4 | import * as log from "lib/log.ts"; | 4 | // these tests for `startWidget` are built on a custom widget host |
| 5 | 5 | // implementation that buffers all bytes in memory. it is a great | |
| 6 | log satisfies log.Scope; // the namespace import is a valid Scope | 6 | // example of how modular the entire system is. |
| 7 | 7 | describe("log widgets", () => { | |
| 8 | test("writeLine buffers", () => { | 8 | test("writeLine buffers", () => { |
| 9 | using host = new TestWidgetHost(); | 9 | using host = new testing.MockScreen(); |
| 10 | host.writeLine("hello world"); | 10 | host.writeLine("hello world"); |
| 11 | host.writeLine("and so on"); | 11 | host.writeLine("and so on"); |
| 12 | host.expectFrame(0, { | 12 | host.expectFrame(0, { |
| 13 | stdout: "hello world\nand so on\n", | 13 | stdout: "hello world\nand so on\n", |
| 14 | }); | 14 | }); |
| 15 | host.writeLine("more logs"); | 15 | host.writeLine("more logs"); |
| 16 | host.expectFrame(0, { | 16 | host.expectFrame(0, { |
| 17 | stdout: "more logs\n", | 17 | stdout: "more logs\n", |
| 18 | }); | ||
| 18 | }); | 19 | }); |
| 19 | }); | ||
| 20 | 20 | ||
| 21 | test("widget with interweaving logs", () => { | 21 | test("widget with interweaving logs", () => { |
| 22 | const host = new TestWidgetHost(); | 22 | using host = new testing.MockScreen(); |
| 23 | 23 | ||
| 24 | const _ = host.startWidget({ | 24 | using _ = host.startWidget({ |
| 25 | format: (now) => `line one ${now}\nline two`, | 25 | format: (now) => `line one ${now}\nline two`, |
| 26 | }); | 26 | }); |
| 27 | 27 | ||
| 28 | host.expectFrame(0, { | 28 | host.expectFrame(0, { |
| 29 | stderr: sync([ | 29 | stderr: testing.MockScreen.sync([ |
| 30 | ansi.startOfLine, | 30 | ansi.startOfLine, |
| 31 | "line one 0\nline two\n", | 31 | "line one 0\nline two\n", |
| 32 | ]), | 32 | ]), |
| 33 | }); | 33 | }); |
| 34 | 34 | ||
| 35 | host.writeLine("info: line of log"); | 35 | host.writeLine("info: line of log"); |
| 36 | host.expectFrame(0, { | 36 | host.expectFrame(0, { |
| 37 | out: sync([ | 37 | merged: testing.MockScreen.sync([ |
| 38 | ansi.startOfLine, | 38 | ansi.startOfLine, |
| 39 | ansi.cursorUp(2), | 39 | ansi.cursorUp(2), |
| 40 | ansi.clearFullLine, | 40 | ansi.clearFullLine, |
| 41 | "info: line of log\n", | 41 | "info: line of log\n", |
| 42 | "line one 0\nline two\n", | 42 | "line one 0\nline two\n", |
| 43 | ]), | 43 | ]), |
| 44 | }); | 44 | }); |
| 45 | 45 | ||
| 46 | host.writeLine("warn: line of log"); | 46 | host.writeLine("warn: line of log"); |
| 47 | host.writeLine("error: line of log"); | 47 | host.writeLine("error: line of log"); |
| 48 | host.expectFrame(0, { | 48 | host.expectFrame(0, { |
| 49 | out: sync([ | 49 | merged: testing.MockScreen.sync([ |
| 50 | ansi.startOfLine, | 50 | ansi.startOfLine, |
| 51 | ansi.cursorUp(1), | 51 | ansi.cursorUp(1), |
| 52 | ansi.clearFullLine, | 52 | ansi.clearFullLine, |
| 53 | ansi.cursorUp(1), | 53 | ansi.cursorUp(1), |
| 54 | ansi.clearFullLine, | 54 | ansi.clearFullLine, |
| 55 | "warn: line of log\n", | 55 | "warn: line of log\n", |
| 56 | "error: line of log\n", | 56 | "error: line of log\n", |
| 57 | "line one 0\nline two\n", | 57 | "line one 0\nline two\n", |
| 58 | ]), | 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; | 62 | test("widget with logs in same frame", () => { |
| 78 | waiter: (() => void) | null = null; | 63 | using host = new testing.MockScreen(); |
| 79 | 64 | ||
| 80 | writeLine: headless.WidgetHost["writeLine"]; | 65 | host.writeLine("log line 1"); |
| 81 | getDrawLock: headless.WidgetHost["getDrawLock"]; | 66 | host.writeLine("log line 2"); |
| 82 | startWidget: headless.WidgetHost["startWidget"]; | ||
| 83 | cancel: headless.WidgetHost["cancel"]; | ||
| 84 | 67 | ||
| 85 | constructor() { | 68 | using _ = host.startWidget({ |
| 86 | const host = headless.widgetHost({ | 69 | format: (now) => `widget line one ${now}\nwidget line two`, |
| 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 | }, | ||
| 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 }: { | 72 | host.expectFrame(0, { |
| 116 | stdout?: string; | 73 | stdout: "log line 1\nlog line 2\n", |
| 117 | stderr?: string; | 74 | merged: testing.MockScreen.sync([ |
| 118 | out?: string; | 75 | "log line 1\n", |
| 119 | }) { | 76 | "log line 2\n", |
| 120 | const { waitDuration } = this; | 77 | "widget line one 0\nwidget line two\n", |
| 121 | ASSERT( | 78 | ]), |
| 122 | ms === waitDuration, | 79 | }); |
| 123 | `expected ${ms}ms to pass, only ${waitDuration}`, | 80 | }); |
| 124 | ); | 81 | }); |
| 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 | } | ||
| 163 | 82 | ||
| 83 | import { describe, test } from "node:test"; | ||
| 164 | import * as ansi from "./string/ansi.ts"; | 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,7 +34,8 @@ |
| 34 | * - otherwise, logs are surfaced using the global `console` API | 34 | * - otherwise, logs are surfaced using the global `console` API |
| 35 | * | 35 | * |
| 36 | * custom log integrations can be built on top of this module by calling | 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 | * @module | 40 | * @module |
| 40 | */ | 41 | */ |
| ... | @@ -123,10 +124,29 @@ export function log(...args: unknown[]) { | ... | @@ -123,10 +124,29 @@ export function log(...args: unknown[]) { |
| 123 | export function debug(...args: unknown[]) { | 124 | export function debug(...args: unknown[]) { |
| 124 | globalLog.debug(...args); | 125 | globalLog.debug(...args); |
| 125 | } | 126 | } |
| 126 | 127 | /** create a named logging scope */ | |
| 127 | export function scoped(name: string): Scope { | 128 | export function scoped(name: string): Scope { |
| 128 | return globalLog.scoped(name); | 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 | * a widget is an interactive display that persists at the end | 152 | * a widget is an interactive display that persists at the end |
| ... | @@ -169,7 +189,7 @@ export function headlessScope(dispatch: DispatchFunction): Scope { | ... | @@ -169,7 +189,7 @@ export function headlessScope(dispatch: DispatchFunction): Scope { |
| 169 | return new Scope(dispatch); | 189 | return new Scope(dispatch); |
| 170 | } | 190 | } |
| 171 | 191 | ||
| 172 | /** See {@linkcode startWidget} */ | 192 | /** see {@linkcode startWidget} */ |
| 173 | export interface Widget { | 193 | export interface Widget { |
| 174 | /** | 194 | /** |
| 175 | * return the widget's text. return null to detach the widget. | 195 | * return the widget's text. return null to detach the widget. |
| ... | @@ -190,17 +210,17 @@ export interface Widget { | ... | @@ -190,17 +210,17 @@ export interface Widget { |
| 190 | 210 | ||
| 191 | /** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */ | 211 | /** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */ |
| 192 | export interface HeadlessWidgetEnv { | 212 | export interface HeadlessWidgetEnv { |
| 193 | /** Recieves ANSI escape sequences for interactive data. */ | 213 | /** recieves ANSI escape sequences for interactive data */ |
| 194 | writeInteractive(text: string): void; | 214 | writeInteractive(text: string): void; |
| 195 | /** Recieves log content (from `writeLine`). */ | 215 | /** recieves log content (from `writeLine`) */ |
| 196 | writeOutput(text: string): void; | 216 | writeOutput(text: string): void; |
| 197 | /** Monotonic. */ | 217 | /** monotonic milliseconds */ |
| 198 | now(): ReturnType<typeof performance.now>; | 218 | now(): ReturnType<typeof performance.now>; |
| 199 | /** 0ms indicates "one frame". */ | 219 | /** after resolving, `now()` should have increased by the delay time */ |
| 200 | wait(ms: number, cb: () => void): () => void; | 220 | delay: typeof async.delay; |
| 201 | /** Called often. */ | 221 | /** called often. */ |
| 202 | getSize(): { columns: number; rows: number }; | 222 | getSize(): { columns: number; rows: number }; |
| 203 | /** Called to enable input events */ | 223 | /** called to enable input events */ |
| 204 | onInput?(write: (bytes: Uint8Array | string) => void): () => void; | 224 | onInput?(write: (bytes: Uint8Array | string) => void): () => void; |
| 205 | } | 225 | } |
| 206 | 226 | ||
| ... | @@ -214,8 +234,13 @@ export interface HeadlessWidgetHost { | ... | @@ -214,8 +234,13 @@ export interface HeadlessWidgetHost { |
| 214 | startWidget(widget: Widget): ts.Dispose; | 234 | startWidget(widget: Widget): ts.Dispose; |
| 215 | /** stop all widgets and remove all timers. */ | 235 | /** stop all widgets and remove all timers. */ |
| 216 | cancel(): void; | 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 | interface WidgetState { | 244 | interface WidgetState { |
| 220 | frameTime: number; | 245 | frameTime: number; |
| 221 | next: number; | 246 | next: number; |
| ... | @@ -238,10 +263,9 @@ interface WidgetState { | ... | @@ -238,10 +263,9 @@ interface WidgetState { |
| 238 | * Points 2 and 3 are used for terminals that are slow or do not support sync. | 263 | * Points 2 and 3 are used for terminals that are slow or do not support sync. |
| 239 | */ | 264 | */ |
| 240 | export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { | 265 | export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 241 | const { writeOutput, writeInteractive, now, wait, getSize } = env; | 266 | const { writeOutput, writeInteractive, now, delay, getSize } = env; |
| 242 | type Cancel = ReturnType<typeof wait>; | ||
| 243 | 267 | ||
| 244 | let timer: Cancel | null = null; | 268 | let timer: async.Cancelable<void> | null = null; |
| 245 | 269 | ||
| 246 | let locks = 0; | 270 | let locks = 0; |
| 247 | let redrawTime = 0; | 271 | let redrawTime = 0; |
| ... | @@ -313,20 +337,22 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { | ... | @@ -313,20 +337,22 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 313 | 337 | ||
| 314 | if (buffer) { | 338 | if (buffer) { |
| 315 | // do not perform diffing since the entire screen is moving down | 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 | let clearLinesTop = Math.min( | 341 | let clearLinesTop = Math.min( |
| 318 | lines.length, | 342 | lines.length, |
| 319 | string.countNewlines(buffer), | 343 | string.countNewlines(buffer), |
| 320 | ); | 344 | ); |
| 321 | const oldLines = lines.slice(clearLinesTop); | 345 | const oldLines = lines.slice(clearLinesTop); |
| 322 | writeInteractive( | 346 | writeInteractive( |
| 323 | ansi.syncStart + ansi.startOfLine + | 347 | ansi.syncStart + (clearLinesTop > 0 |
| 324 | // skip up the shared widget space | 348 | ? ansi.startOfLine + |
| 325 | ansi.cursorUp(lines.length - clearLinesTop + 1) + | 349 | // skip up the shared widget space |
| 326 | // clear the lines to contain `buffer` | 350 | ansi.cursorUp(lines.length - clearLinesTop + 1) + |
| 327 | ansi.clearFullLine + | 351 | // clear the lines to contain `buffer` |
| 328 | (ansi.cursorUp(1) + ansi.clearFullLine) | 352 | ansi.clearFullLine + |
| 329 | .repeat(clearLinesTop - 1), | 353 | (ansi.cursorUp(1) + ansi.clearFullLine) |
| 354 | .repeat(clearLinesTop - 1) | ||
| 355 | : ""), | ||
| 330 | ); | 356 | ); |
| 331 | // then write output lines on standard out | 357 | // then write output lines on standard out |
| 332 | writeOutput(buffer); | 358 | writeOutput(buffer); |
| ... | @@ -385,15 +411,16 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { | ... | @@ -385,15 +411,16 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 385 | const newRedrawTime = now() + ms; | 411 | const newRedrawTime = now() + ms; |
| 386 | if (timer) { | 412 | if (timer) { |
| 387 | if (redrawTime < newRedrawTime) return; | 413 | if (redrawTime < newRedrawTime) return; |
| 388 | timer(); // cancel previous | 414 | timer.cancel(); // cancel previous |
| 389 | timer = null; | 415 | timer = null; |
| 390 | } | 416 | } |
| 391 | redrawTime = newRedrawTime + 1; | 417 | redrawTime = newRedrawTime + 1; |
| 392 | timer = wait(ms, redrawCallback); | 418 | timer = delay(ms); |
| 419 | timer.then(redrawCallback); | ||
| 393 | } | 420 | } |
| 394 | 421 | ||
| 395 | function flushAndClear() { | 422 | function flushAndClear() { |
| 396 | timer?.(); | 423 | timer?.cancel(); |
| 397 | timer = null; | 424 | timer = null; |
| 398 | if (lines.length > 0) { | 425 | if (lines.length > 0) { |
| 399 | // clear widgets | 426 | // clear widgets |
| ... | @@ -442,6 +469,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { | ... | @@ -442,6 +469,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 442 | cancel() { | 469 | cancel() { |
| 443 | flushAndClear(); | 470 | flushAndClear(); |
| 444 | }, | 471 | }, |
| 472 | delay, | ||
| 473 | now, | ||
| 445 | }; | 474 | }; |
| 446 | } | 475 | } |
| 447 | 476 | ||
| ... | @@ -469,6 +498,7 @@ let formatLine = /* @__PURE__ */ (() => { | ... | @@ -469,6 +498,7 @@ let formatLine = /* @__PURE__ */ (() => { |
| 469 | let withinDispatch = false; | 498 | let withinDispatch = false; |
| 470 | let withinStackCapture = false; | 499 | let withinStackCapture = false; |
| 471 | 500 | ||
| 501 | /** this class is an implementation detail */ | ||
| 472 | const Scope = class Scope implements Scope { | 502 | const Scope = class Scope implements Scope { |
| 473 | name: string | undefined; | 503 | name: string | undefined; |
| 474 | // TODO: this abstraction implementation has low performance. making every | 504 | // TODO: this abstraction implementation has low performance. making every |
| ... | @@ -544,7 +574,7 @@ const Scope = class Scope implements Scope { | ... | @@ -544,7 +574,7 @@ const Scope = class Scope implements Scope { |
| 544 | }; | 574 | }; |
| 545 | 575 | ||
| 546 | const globalWidgetHost = /* @__PURE__ */ (() => { | 576 | const globalWidgetHost = /* @__PURE__ */ (() => { |
| 547 | // TODO; write this in a tree-shakable manner | 577 | // TODO; write this in a more tree-shakable manner |
| 548 | const { process } = node; | 578 | const { process } = node; |
| 549 | if (!process || !process.stderr.isTTY) { | 579 | if (!process || !process.stderr.isTTY) { |
| 550 | // return a no-op | 580 | // return a no-op |
| ... | @@ -570,10 +600,7 @@ const globalWidgetHost = /* @__PURE__ */ (() => { | ... | @@ -570,10 +600,7 @@ const globalWidgetHost = /* @__PURE__ */ (() => { |
| 570 | writeOutput: (string) => process.stdout.write(string), | 600 | writeOutput: (string) => process.stdout.write(string), |
| 571 | writeInteractive: (string) => process.stderr.write(string), | 601 | writeInteractive: (string) => process.stderr.write(string), |
| 572 | now: () => performance.now(), | 602 | now: () => performance.now(), |
| 573 | wait: (ms, cb) => { | 603 | delay: async.delay, |
| 574 | const id = setTimeout(cb, ms); | ||
| 575 | return () => clearTimeout(id); | ||
| 576 | }, | ||
| 577 | getSize: () => process.stderr, | 604 | getSize: () => process.stderr, |
| 578 | }); | 605 | }); |
| 579 | process.addListener("beforeExit", () => widget.cancel()); | 606 | process.addListener("beforeExit", () => widget.cancel()); |
| ... | @@ -603,23 +630,29 @@ const levelToAnsi: Record<Message["level"], string> = { | ... | @@ -603,23 +630,29 @@ const levelToAnsi: Record<Message["level"], string> = { |
| 603 | debug: `${ansi.dim}dbg`, | 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 | let globalOutputFunction!: DispatchFunction; | 649 | let globalOutputFunction!: DispatchFunction; |
| 607 | const globalLog = /* @__PURE__ */ (() => { | 650 | const globalLog = /* @__PURE__ */ (() => { |
| 608 | const colors = node.process?.stderr.isTTY ?? false; | 651 | const colors = node.process?.stderr.isTTY ?? false; |
| 609 | globalOutputFunction = node.process | 652 | globalOutputFunction = node.process |
| 610 | // In Node.js, coordinate with the widget host | 653 | // In Node.js, coordinate with the widget host |
| 611 | ? ({ level, scope, text }) => { | 654 | ? (message) => { |
| 612 | if (!text) return globalWidgetHost.writeLine(""); | 655 | globalWidgetHost.writeLine(globalMessageFormatFunction(message, colors)); |
| 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); | ||
| 623 | } | 656 | } |
| 624 | // Otherwise, forward to `console` | 657 | // Otherwise, forward to `console` |
| 625 | : (m) => { | 658 | : (m) => { |
| ... | @@ -635,11 +668,14 @@ const globalLog = /* @__PURE__ */ (() => { | ... | @@ -635,11 +668,14 @@ const globalLog = /* @__PURE__ */ (() => { |
| 635 | return new Scope(globalOutputFunction); | 668 | return new Scope(globalOutputFunction); |
| 636 | })(); | 669 | })(); |
| 637 | 670 | ||
| 638 | export interface DispatchFunction { | 671 | export type DispatchFunction = (message: Message) => void; |
| 639 | (message: Message): void; | 672 | export type MessageFormatFunction = ( |
| 640 | } | 673 | message: Message, |
| 674 | colors: boolean, | ||
| 675 | ) => string; | ||
| 641 | 676 | ||
| 642 | import * as ansi from "./string/ansi.ts"; | 677 | import * as ansi from "./string/ansi.ts"; |
| 678 | import * as async from "./async.ts"; | ||
| 643 | import * as node from "./node.ts"; | 679 | import * as node from "./node.ts"; |
| 644 | import * as stack from "./log/stack.ts"; | 680 | import * as stack from "./log/stack.ts"; |
| 645 | import * as string from "./string.ts"; | 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"; | ||