authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-15 23:36:01-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-14 01:08:16-08:00
loga74b35e546be0d1d10c1da28e8daedec77ad69b4
tree47e2195337d9cecf2543495c156dbffb4ef04cf3
parente577adcfbe4eb4c80434c05b23beee441458d5a4
signature Commit is signed but in an unrecognized format.

feat(lib/log): output redirection with `tee`

also resolves #40

3 files changed, 391 insertions(+), 194 deletions(-)

lib/log.test.ts+72-150
...@@ -1,164 +1,86 @@...@@ -1,164 +1,86 @@
1import { ASSERT, UNWRAP } from "lib/assert.ts";1// the namespace import is a valid Scope
2import { test } from "node:test";2log satisfies log.Scope;
3import * as headless from "lib/log/headless.ts";3
4import * as log from "lib/log.ts";4// these tests for `startWidget` are built on a custom widget host
55// implementation that buffers all bytes in memory. it is a great
6log satisfies log.Scope; // the namespace import is a valid Scope6// example of how modular the entire system is.
77describe("log widgets", () => {
8test("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});
2020
21test("widget with interweaving logs", () => {21 test("widget with interweaving logs", () => {
22 const host = new TestWidgetHost();22 using host = new testing.MockScreen();
2323
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 });
2727
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 });
3434
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 });
4545
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
62function sync(text: string[]) {
63 return ansi.syncStart + text.join("") + ansi.syncEnd;
64}
65
66class 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;
7661
77 waitDuration = 0;62 test("widget with logs in same frame", () => {
78 waiter: (() => void) | null = null;63 using host = new testing.MockScreen();
7964
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"];
8467
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 }
11471
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}
16382
83import { describe, test } from "node:test";
164import * as ansi from "./string/ansi.ts";84import * as ansi from "./string/ansi.ts";
85import * as log from "./log.ts";
86import * 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` API34 * - otherwise, logs are surfaced using the global `console` API
35 *35 *
36 * custom log integrations can be built on top of this module by calling36 * 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 up37 * `log.tee()` to duplicate all messages elsewhere. for example, a project may
38 * configure logs to upload to a telemetry service.
38 *39 *
39 * @module40 * @module
40 */41 */
...@@ -123,10 +124,29 @@ export function log(...args: unknown[]) {...@@ -123,10 +124,29 @@ export function log(...args: unknown[]) {
123export function debug(...args: unknown[]) {124export function debug(...args: unknown[]) {
124 globalLog.debug(...args);125 globalLog.debug(...args);
125}126}
126127/** create a named logging scope */
127export function scoped(name: string): Scope {128export 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 */
132export function tee(destination: DispatchFunction): ts.Dispose {
133 return globalLog.tee(destination);
134}
135/** replace the default writer */
136export function replaceGlobalDestination(destination: DispatchFunction) {
137 globalOutputFunction = destination;
138}
139
140/** replace the default formatter */
141export function replaceGlobalFormatFunction(
142 format: (msg: Message, colors: boolean) => string,
143) {
144 globalMessageFormatFunction = format;
145}
146
147export function formatMessage(msg: Message, colors: boolean) {
148 return globalMessageFormatFunction(msg, colors);
149}
130150
131/**151/**
132 * a widget is an interactive display that persists at the end152 * 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}
171191
172/** See {@linkcode startWidget} */192/** see {@linkcode startWidget} */
173export interface Widget {193export 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 {
190210
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 */
192export interface HeadlessWidgetEnv {212export 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}
206226
...@@ -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}
218242
243/** @internal state */
219interface WidgetState {244interface 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 */
240export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {265export 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>;
243267
244 let timer: Cancel | null = null;268 let timer: async.Cancelable<void> | null = null;
245269
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 {
313337
314 if (buffer) {338 if (buffer) {
315 // do not perform diffing since the entire screen is moving down339 // 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 space348 ? 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 out357 // 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 previous414 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 }
394421
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 widgets426 // 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}
447476
...@@ -469,6 +498,7 @@ let formatLine = /* @__PURE__ */ (() => {...@@ -469,6 +498,7 @@ let formatLine = /* @__PURE__ */ (() => {
469let withinDispatch = false;498let withinDispatch = false;
470let withinStackCapture = false;499let withinStackCapture = false;
471500
501/** this class is an implementation detail */
472const Scope = class Scope implements Scope {502const Scope = class Scope implements Scope {
473 name: string | undefined;503 name: string | undefined;
474 // TODO: this abstraction implementation has low performance. making every504 // 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};
545575
546const globalWidgetHost = /* @__PURE__ */ (() => {576const globalWidgetHost = /* @__PURE__ */ (() => {
547 // TODO; write this in a tree-shakable manner577 // 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-op580 // 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};
605632
633let 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};
606let globalOutputFunction!: DispatchFunction;649let globalOutputFunction!: DispatchFunction;
607const globalLog = /* @__PURE__ */ (() => {650const globalLog = /* @__PURE__ */ (() => {
608 const colors = node.process?.stderr.isTTY ?? false;651 const colors = node.process?.stderr.isTTY ?? false;
609 globalOutputFunction = node.process652 globalOutputFunction = node.process
610 // In Node.js, coordinate with the widget host653 // 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})();
637670
638export interface DispatchFunction {671export type DispatchFunction = (message: Message) => void;
639 (message: Message): void;672export type MessageFormatFunction = (
640}673 message: Message,
674 colors: boolean,
675) => string;
641676
642import * as ansi from "./string/ansi.ts";677import * as ansi from "./string/ansi.ts";
678import * as async from "./async.ts";
643import * as node from "./node.ts";679import * as node from "./node.ts";
644import * as stack from "./log/stack.ts";680import * as stack from "./log/stack.ts";
645import * as string from "./string.ts";681import * 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 */
14export 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. */
113export 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
235import * as ansi from "lib/string/ansi.ts";
236import { ASSERT, UNWRAP } from "lib/assert.ts";
237import * as log from "lib/log.ts";
238import * as async from "lib/async.ts";
239import * as stack from "lib/log/stack.ts";