authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-12 21:50:39-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-21 18:03:28-07:00
log76e57112a9715234e63ea77458888e56836e6286
treef7386f738942c940b8ab1c3e6c928e7355476958
parent5d86be6df8d71ae44648092495743af3856e7a43
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat(lib/log): messages without newlines + terminal lock

resolves #70 by renaming `writeLine` to `write`, which also meant reworking the message interface to support messages that do not end in a newline. this is a critical oversight that progress library needs. now, the global log or a progress nodes support this, which is great for unstructured data streams. additionally, the way the widget host works has been drastically altered, it works off of a terminal lock. this implements the vision in #65. in the future, i'll make it so interweaving scopes can display cleanly.

6 files changed, 695 insertions(+), 238 deletions(-)

lib/log.test.ts+274-18
...@@ -5,21 +5,135 @@ log satisfies log.Scope;...@@ -5,21 +5,135 @@ log satisfies log.Scope;
5// implementation that buffers all bytes in memory. it is a great5// implementation that buffers all bytes in memory. it is a great
6// example of how modular the entire system is.6// example of how modular the entire system is.
7describe("log widgets", () => {7describe("log widgets", () => {
8 test("writeLine buffers", () => {8 test("write buffering", () => {
9 using host = new testing.MockScreen();9 const host = new testing.MockScreen();
10 host.writeLine("hello world");10 host.write("hello world ");
11 host.writeLine("and so on");11 host.write("and so on\n");
12 host.expectFrame(0, {12 host.expectFrame(0, {
13 stdout: "hello world\nand so on\n",13 stdout: "hello world and so on\n",
14 });14 });
15 host.writeLine("more logs");15 host.write("more logs\n");
16 host.expectFrame(0, {16 host.expectFrame(0, {
17 stdout: "more logs\n",17 stdout: "more logs\n",
18 });18 });
19 host.write("abc def");
20 {
21 const unlock = host.getDrawLock();
22 host.expectFrame(null, {
23 stdout: "abc def",
24 });
25 unlock();
26 }
27 host.expectFrame(null, { stdout: "" });
28 {
29 const unlock = host.getDrawLock();
30 host.write("abc def ");
31 host.write("hijk");
32 unlock();
33 host.expectFrame(0, {
34 stdout: "abc def hijk",
35 });
36 }
37 host.expectFrame(null, { stdout: "" });
38 host.cancel();
39 });
40
41 test("widget partial line", () => {
42 const host = new testing.MockScreen();
43 const wd1: log.Widget = { format: (now) => `t=${now}` };
44 const w1 = host.startWidget(wd1);
45 host.expectFrame(0, {
46 stderr: testing.MockScreen.sync([
47 "t=0\n",
48 ]),
49 });
50 host.expectNone();
51 host.write("partial line");
52 host.expectFrame(0, {
53 merged: testing.MockScreen.sync([
54 ansi.cursorUp(1),
55 ansi.clearFullLine,
56 "partial line",
57 ansi.cursorSave,
58 "\n",
59 "t=0\n",
60 ]),
61 stdout: "partial line",
62 stderr: testing.MockScreen.sync([
63 ansi.cursorUp(1),
64 ansi.clearFullLine,
65 ansi.cursorSave,
66 "\n",
67 "t=0\n",
68 ]),
69 });
70 host.write(" and more\n");
71 host.expectFrame(0, {
72 merged: testing.MockScreen.sync([
73 ansi.cursorRestore,
74 " and more\n",
75 "t=0\n",
76 ]),
77 stdout: " and more\n",
78 stderr: testing.MockScreen.sync([
79 ansi.cursorRestore,
80 "t=0\n",
81 ]),
82 });
83 host.cancel();
84 });
85
86 test("widget partial line 2", () => {
87 const host = new testing.MockScreen();
88 const wd1: log.Widget = { format: (now) => `abc\ndefg` };
89 const w1 = host.startWidget(wd1);
90 host.expectFrame(0, {
91 stderr: testing.MockScreen.sync([
92 "abc\ndefg\n",
93 ]),
94 });
95 // abc
96 // defg
97 host.expectNone();
98 host.write("partial line");
99 // abc
100 // defg
101 // [c]
102 host.expectFrame(0, {
103 merged: testing.MockScreen.sync([
104 ansi.cursorUp(2),
105 ansi.clearFullLine,
106 "partial line",
107 ansi.cursorSave + "\n",
108 "abc",
109 ansi.clearToEndOfLine,
110 "\ndefg\n",
111 ]),
112 });
113 host.write(" and more\na");
114 // partial line[T]
115 // abc
116 // defg
117 // [CURSOR]
118 // move up 3, right 12
119 host.expectFrame(0, {
120 merged: testing.MockScreen.sync([
121 ansi.cursorUp(2),
122 ansi.clearFullLine,
123 ansi.cursorRestore,
124 " and more\n",
125 "a",
126 ansi.cursorSave + "\n",
127 "abc",
128 ansi.clearToEndOfLine,
129 "\ndefg\n",
130 ]),
131 });
132 host.cancel();
19 });133 });
20134
21 test("widget with interweaving logs", () => {135 test("widget with interweaving logs", () => {
22 using host = new testing.MockScreen();136 const host = new testing.MockScreen();
23137
24 using _ = host.startWidget({138 using _ = host.startWidget({
25 format: (now) => `line one ${now}\nline two`,139 format: (now) => `line one ${now}\nline two`,
...@@ -27,15 +141,13 @@ describe("log widgets", () => {...@@ -27,15 +141,13 @@ describe("log widgets", () => {
27141
28 host.expectFrame(0, {142 host.expectFrame(0, {
29 stderr: testing.MockScreen.sync([143 stderr: testing.MockScreen.sync([
30 ansi.startOfLine,
31 "line one 0\nline two\n",144 "line one 0\nline two\n",
32 ]),145 ]),
33 });146 });
34147
35 host.writeLine("info: line of log");148 host.write("info: line of log\n");
36 host.expectFrame(0, {149 host.expectFrame(0, {
37 merged: testing.MockScreen.sync([150 merged: testing.MockScreen.sync([
38 ansi.startOfLine,
39 ansi.cursorUp(2),151 ansi.cursorUp(2),
40 ansi.clearFullLine,152 ansi.clearFullLine,
41 "info: line of log\n",153 "info: line of log\n",
...@@ -43,11 +155,10 @@ describe("log widgets", () => {...@@ -43,11 +155,10 @@ describe("log widgets", () => {
43 ]),155 ]),
44 });156 });
45157
46 host.writeLine("warn: line of log");158 host.write("warn: line of log\n");
47 host.writeLine("error: line of log");159 host.write("error: line of log\n");
48 host.expectFrame(0, {160 host.expectFrame(0, {
49 merged: testing.MockScreen.sync([161 merged: testing.MockScreen.sync([
50 ansi.startOfLine,
51 ansi.cursorUp(1),162 ansi.cursorUp(1),
52 ansi.clearFullLine,163 ansi.clearFullLine,
53 ansi.cursorUp(1),164 ansi.cursorUp(1),
...@@ -57,16 +168,112 @@ describe("log widgets", () => {...@@ -57,16 +168,112 @@ describe("log widgets", () => {
57 "line one 0\nline two\n",168 "line one 0\nline two\n",
58 ]),169 ]),
59 });170 });
171 host.cancel();
172 });
173
174 test("temporary drawing lock", () => {
175 const host = new testing.MockScreen();
176
177 using _ = host.startWidget({
178 format: (now) => `line one ${now}\nline two`,
179 });
180
181 host.expectFrame(0, {
182 stderr: testing.MockScreen.sync([
183 "line one 0\nline two\n",
184 ]),
185 });
186
187 host.write("info: line of log\n");
188 host.expectFrame(0, {
189 merged: testing.MockScreen.sync([
190 ansi.cursorUp(2),
191 ansi.clearFullLine,
192 "info: line of log\n",
193 "line one 0\nline two\n",
194 ]),
195 });
196
197 {
198 host.write("warn: line of log\n");
199 host.expectWithoutConsume(0);
200 const unlock = host.getDrawLock("short");
201 // info: line of log
202 // line one 0
203 // line two
204 // [C]
205 host.expectFrame(null, {
206 merged: [
207 ansi.syncStart,
208 ansi.cursorUp(1),
209 ansi.clearFullLine,
210 ansi.cursorUp(1),
211 ansi.clearFullLine,
212 "warn: line of log\n",
213 ].join(""),
214 stdout: "warn: line of log\n",
215 });
216 const unlock2 = host.getDrawLock("long");
217 host.expectNone();
218 host.write("error: line of log\n");
219 host.expectNone();
220 unlock2();
221 host.expectNone();
222 unlock();
223 // warn: line of log
224 // [c]
225 host.expectFrame(0, {
226 merged: [
227 "error: line of log\n",
228 "line one 0\nline two\n",
229 ansi.syncEnd,
230 ].join(""),
231 });
232 host.cancel();
233 }
234 {
235 const unlock = host.getDrawLock("short");
236 // info: line of log
237 // line one 0
238 // line two
239 // [C]
240 host.expectFrame(null, {
241 merged: [
242 ansi.syncStart,
243 ansi.cursorUp(1),
244 ansi.clearFullLine,
245 ansi.cursorUp(1),
246 ansi.clearFullLine,
247 ].join(""),
248 stdout: "",
249 });
250 const unlock2 = host.getDrawLock("long");
251 host.expectNone();
252 unlock2();
253 host.expectNone();
254 unlock();
255 // warn: line of log
256 // [c]
257 host.expectFrame(0, {
258 merged: [
259 "line one 0\nline two\n",
260 ansi.syncEnd,
261 ].join(""),
262 });
263 host.cancel();
264 }
60 });265 });
61266
62 test("widget with logs in same frame", () => {267 test("widget expires", () => {
63 using host = new testing.MockScreen();268 const host = new testing.MockScreen();
64269
65 host.writeLine("log line 1");270 host.write("log line 1\n");
66 host.writeLine("log line 2");271 host.write("log line 2\n");
67272
68 using _ = host.startWidget({273 using _ = host.startWidget({
69 format: (now) => `widget line one ${now}\nwidget line two`,274 format: (now) =>
275 now > 1000 ? null : `widget line one ${now}\nwidget line two`,
276 fps: 1,
70 });277 });
71278
72 host.expectFrame(0, {279 host.expectFrame(0, {
...@@ -77,6 +284,55 @@ describe("log widgets", () => {...@@ -77,6 +284,55 @@ describe("log widgets", () => {
77 "widget line one 0\nwidget line two\n",284 "widget line one 0\nwidget line two\n",
78 ]),285 ]),
79 });286 });
287 host.expectFrame(1000, {
288 merged: testing.MockScreen.sync([
289 ansi.cursorUp(2),
290 "widget line one 1000\nwidget line two\n",
291 ]),
292 });
293 host.expectFrame(1000, {
294 merged: testing.MockScreen.sync([
295 ansi.cursorUp(1),
296 ansi.clearFullLine,
297 ansi.cursorUp(1),
298 ansi.clearFullLine,
299 ]),
300 });
301 host.cancel();
302 });
303
304 test("widget expires partial line output", () => {
305 const host = new testing.MockScreen();
306
307 host.write("log line;");
308
309 using _ = host.startWidget({
310 format: (now) =>
311 now > 500 ? null : `widget line one ${now}\nwidget line two`,
312 fps: 1,
313 });
314
315 host.expectFrame(0, {
316 stdout: "log line;",
317 merged: testing.MockScreen.sync([
318 "log line;",
319 ansi.cursorSave + "\n",
320 "widget line one 0\nwidget line two\n",
321 ]),
322 });
323 host.write(" rest of line\n");
324 host.expectFrame(1000, {
325 stdout: " rest of line\n",
326 merged: testing.MockScreen.sync([
327 ansi.cursorUp(1),
328 ansi.clearFullLine,
329 ansi.cursorUp(1),
330 ansi.clearFullLine,
331 ansi.cursorRestore,
332 " rest of line\n",
333 ]),
334 });
335 host.cancel();
80 });336 });
81});337});
82338
lib/log.ts+323-146
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1/**1/**
2 * by using `lib/log.ts`, an application gets easy scoped logging as well as2 * by using `lib/log.ts`, an application gets easy scoped logging as well as
3 * integration with terminal widgets such as `lib/progress.ts`.3 * integration with terminal widgets such as `lib/progress.ts`. even when these
4 * widgets are active, using the logging interface is optional; global I/O with
5 * `console.*` and `process.std{out/err}` are patched to play nice.
4 *6 *
5 * the pattern for using this module is to shadow the global `console` with a7 * the pattern for using this module is to shadow the global `console` with a
6 * per-file logging scope:8 * per-file logging scope, which makes it impossible to use the wrong logger.
7 *9 *
8 * ```ts10 * ```ts
9 * import * as log from "@clo/lib/log";11 * import * as log from "@clo/lib/log";
...@@ -21,17 +23,19 @@...@@ -21,17 +23,19 @@
21 * now, the code reads familiarly (`console.log` is universally understood),23 * now, the code reads familiarly (`console.log` is universally understood),
22 * but the output is organized into relevant scopes.24 * but the output is organized into relevant scopes.
23 *25 *
24 * in addition to static log messages, a system for interactive I/O26 * in addition to static log messages, a system for interactive I/O via the
25 * {@linkcode Widget} is provided by calling {@linkcode startWidget}. these27 * {@linkcode Widget} interface can be started with {@linkcode startWidget}.
26 * allow showing temporary or interactive information, such as program status28 * these allow showing temporary or interactive information, such as program
27 * or input prompts. a powerful example of this system in action is29 * status or input prompts. a powerful example of this system in action is
28 * `lib/progress.ts`, which uses a log widget by default to show status.30 * `lib/progress.ts`, which uses a widget as it's default rendering backend.
29 * (TODO: widgets cannot recieve "input" data yet)31 * (TODO: widgets cannot recieve "input" data yet)
30 *32 *
31 * this module offers two environment integrations:33 * `lib/log.ts` offers two environment integrations:
32 * - in node.js, log messages are colored depending on the level and show34 * - in node.js, log messages are colored depending on the level and show
33 * widgets directly under the long using ANSI cursor controls.35 * widgets directly under the long using ANSI cursor controls. when the
34 * - otherwise, logs are surfaced using the global `console` API36 * terminal is not a TTY, widgets are silent.
37 * - in browsers and other, logs are surfaced using the global `console` API
38 * and widgets are disabled.
35 *39 *
36 * custom log integrations can be built on top of this module by calling40 * 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 may41 * `log.tee()` to duplicate all messages elsewhere. for example, a project may
...@@ -72,6 +76,11 @@ export interface Scope {...@@ -72,6 +76,11 @@ export interface Scope {
72 tee(writer: (message: Message) => void): ts.Dispose;76 tee(writer: (message: Message) => void): ts.Dispose;
73}77}
7478
79export interface RootScope extends Scope {
80 /** write a partial line to the output. */
81 write(text: string): void;
82}
83
75export const originalLogArgs = Symbol("originalLogArgs");84export const originalLogArgs = Symbol("originalLogArgs");
76export interface Message {85export interface Message {
77 level: "error" | "warn" | "info" | "debug";86 level: "error" | "warn" | "info" | "debug";
...@@ -85,6 +94,8 @@ export interface Message {...@@ -85,6 +94,8 @@ export interface Message {
85 stack?: stack.Frame[];94 stack?: stack.Frame[];
86 /** arbitrary data from the logging source. */95 /** arbitrary data from the logging source. */
87 custom?: Partial<Record<string, ts.Json>>;96 custom?: Partial<Record<string, ts.Json>>;
97 /** print a newline at the end of this log line? */
98 newline?: boolean;
88 /**99 /**
89 * original logging arguments, if present. this field is indexed by a symbol100 * original logging arguments, if present. this field is indexed by a symbol
90 * so that it is lost during JSON serialization, as callers are allowed to log101 * so that it is lost during JSON serialization, as callers are allowed to log
...@@ -140,6 +151,7 @@ export function replaceGlobalFormatFunction(...@@ -140,6 +151,7 @@ export function replaceGlobalFormatFunction(
140 globalMessageFormatFunction = format;151 globalMessageFormatFunction = format;
141}152}
142153
154/** includes the trailing newline for standard log messages */
143export function formatMessage(msg: Message, colors: boolean): string {155export function formatMessage(msg: Message, colors: boolean): string {
144 return globalMessageFormatFunction(msg, colors);156 return globalMessageFormatFunction(msg, colors);
145}157}
...@@ -169,21 +181,23 @@ export function startWidget(widget: Widget): ts.Dispose {...@@ -169,21 +181,23 @@ export function startWidget(widget: Widget): ts.Dispose {
169 * no built-in prefix or formatting. ensures the text does not interweave. data181 * no built-in prefix or formatting. ensures the text does not interweave. data
170 * will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.182 * will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.
171 */183 */
172export function writeLine(text: string) {184export function write(text: string) {
173 globalWidgetHost.writeLine(text);185 globalWidgetHost.write(text);
174}186}
175187
176/** write a Message object directly. */188/** write a {@linkcode Message} object directly. */
177export function writeMessage(m: Message) {189export function writeMessage(m: Message) {
178 globalLog.writeMessage(m);190 globalLog.writeMessage(m);
179}191}
180192
181/**193/**
182 * while locked, no widgets will draw. prefer `writeLine`.194 * while locked, no widgets will draw. prefer calling `write` to opt into
183 * this lock is not exclusive.195 * automatic buffering. this lock is not exclusive. if the lock will be held
196 * for an extremely short amount of time, pass `"short"` which will allow
197 * more optimized use of ansi synchronization codes.
184 */198 */
185export function getDrawLock(): ts.Dispose {199export function getDrawLock(mode: "long" | "short"): ts.Dispose {
186 return globalWidgetHost.getDrawLock();200 return globalWidgetHost.getDrawLock(mode);
187}201}
188202
189export function headlessScope(dispatch: DispatchFunction): Scope {203export function headlessScope(dispatch: DispatchFunction): Scope {
...@@ -196,11 +210,13 @@ export interface Widget {...@@ -196,11 +210,13 @@ export interface Widget {
196 * return the widget's text. return null to detach the widget.210 * return the widget's text. return null to detach the widget.
197 * may get called more often than the specified `fps`.211 * may get called more often than the specified `fps`.
198 * supports color codes but not ansi cursor movements.212 * supports color codes but not ansi cursor movements.
199 */ format(213 */
214 format(
200 now: ReturnType<typeof performance.now>,215 now: ReturnType<typeof performance.now>,
201 ):216 ):
202 | string217 | string
203 | null; /** 'null' to never update (use 'onChange'). defaults to 12 fps */218 | null;
219 /** 'null' to never update (use 'onChange') */
204 fps?:220 fps?:
205 | number221 | number
206 | null; /** Subscribe to manual widget updates. Call `rerender` when needed. */222 | null; /** Subscribe to manual widget updates. Call `rerender` when needed. */
...@@ -211,26 +227,49 @@ export interface Widget {...@@ -211,26 +227,49 @@ export interface Widget {
211227
212/** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */228/** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */
213export interface HeadlessWidgetEnv {229export interface HeadlessWidgetEnv {
214 /** recieves ANSI escape sequences for interactive data */230 /**
215 writeInteractive(text: string): void;231 * an exclusive lock on the terminal is held whenever widgets are active. a
216 /** recieves log content (from `writeLine`) */232 * secondary purpose of this is to instrument/deinstrument other code to
217 writeOutput(text: string): void;233 * integrate with `log.ts`'s widget lock. for example, the node.js adapter
234 * will patch `process.std{out,err}` to ensure write calls properly get a
235 * draw lock.
236 *
237 * this lock can be cleared by deactiving all widgets, or by calling
238 * {@linkcode getDrawLock}.
239 *
240 * currently, this lock must be able to be synchronously aquired at any
241 * point. if you desire an async locking function, please contact me so we
242 * can design how it would work. you can currently work around this with
243 * `getDrawLock`
244 */
245 lockTerminal: () => TerminalLock;
246 /** fast path for writing output without widgets */
247 writeOutputTemporaryLock?: (buffer: string) => void;
218 /** monotonic milliseconds */248 /** monotonic milliseconds */
219 now(): ReturnType<typeof performance.now>;249 now: () => ReturnType<typeof performance.now>;
220 /** after resolving, `now()` should have increased by the delay time */250 /** after resolving, `now()` should have increased by the delay time */
221 delay: typeof async.delay;251 delay: typeof async.delay;
222 /** called often. */252}
253
254export interface TerminalLock {
255 /** recieves ANSI escape sequences for interactive data (should flush immediately) */
256 writeInteractive(text: string): void;
257 /** recieves log content from `write` (pre-buffered; should flush immediately) */
258 writeOutput(text: string): void;
259 /** called often. TODO: convert this into a subscription */
223 getSize(): { columns: number; rows: number };260 getSize(): { columns: number; rows: number };
224 /** called to enable input events */261 /** temporarily free the lock */
225 onInput?(write: (bytes: Uint8Array | string) => void): () => void;262 temporaryUnlock?(): () => void;
263 /** completely free the lock */
264 close(): void;
226}265}
227266
228/** an implementation of an ANSI-based widget host */267/** an implementation of an ANSI-based widget host */
229export interface HeadlessWidgetHost {268export interface HeadlessWidgetHost {
230 /** see the top-level {@linkcode writeLine} function */269 /** see the top-level {@linkcode writeLine} function */
231 writeLine(text: string): void;270 write(text: string): void;
232 /** see the top-level {@linkcode getDrawLock} function */271 /** see the top-level {@linkcode getDrawLock} function */
233 getDrawLock(): ts.Dispose;272 getDrawLock(mode: "long" | "short"): ts.Dispose;
234 /** see the top-level {@linkcode startWidget} function */273 /** see the top-level {@linkcode startWidget} function */
235 startWidget(widget: Widget): ts.Dispose;274 startWidget(widget: Widget): ts.Dispose;
236 /** stop all widgets and remove all timers. */275 /** stop all widgets and remove all timers. */
...@@ -258,13 +297,13 @@ interface WidgetState {...@@ -258,13 +297,13 @@ interface WidgetState {
258 * - Maximum of one `wait` call at once. When the expected time suddenly297 * - Maximum of one `wait` call at once. When the expected time suddenly
259 * shrinks, the timer is rescheduled.298 * shrinks, the timer is rescheduled.
260 * - When redrawing widget lines, three tricks are done to reduce flickering:299 * - When redrawing widget lines, three tricks are done to reduce flickering:
261 * 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd)300 * 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd).
262 * 2. A simple prefix-based diffing algorithm for skipping unchanged text301 * 2. A simple prefix-based diffing algorithm for skipping unchanged text
263 * 3. Avoid clearing a line before redrawing it.302 * 3. Avoid clearing a line before redrawing it.
264 * Points 2 and 3 are used for terminals that are slow or do not support sync.303 * Points 2 and 3 are used for terminals that are slow or do not support sync.
265 */304 */
266export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {305export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
267 const { writeOutput, writeInteractive, now, delay, getSize } = env;306 const { lockTerminal, now, delay, writeOutputTemporaryLock } = env;
268307
269 let timer: async.Cancelable<void> | null = null;308 let timer: async.Cancelable<void> | null = null;
270309
...@@ -272,113 +311,146 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -272,113 +311,146 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
272 let redrawTime = 0;311 let redrawTime = 0;
273 let lastFlush = 0;312 let lastFlush = 0;
274 let buffer = "";313 let buffer = "";
314 let partialLine = false;
275 const widgets: Widget[] = [];315 const widgets: Widget[] = [];
276 const internals: WidgetState[] = [];316 const internals: WidgetState[] = [];
277 let lines: string[] = [];317 let lines: string[] = [];
318 let hasSyncStart = false;
319 let terminal: TerminalLock | null = null;
320 let tempUnlock: (() => void) | null = null;
278321
279 function redrawCallback() {322 function redrawCallback() {
280 timer = null;323 timer = null;
281 redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround324 redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround
282325
326 // trivial path when not using widgets
283 if (!lines.length && !widgets.length) {327 if (!lines.length && !widgets.length) {
284 buffer && writeOutput(buffer);328 ASSERT(buffer);
329 if (writeOutputTemporaryLock) {
330 writeOutputTemporaryLock(buffer);
331 if (hasSyncStart) {
332 terminal ??= lockTerminal();
333 terminal.writeInteractive(ansi.syncEnd);
334 }
335 } else {
336 terminal ??= lockTerminal();
337 buffer && terminal.writeOutput(buffer);
338 if (hasSyncStart) {
339 terminal ??= lockTerminal();
340 terminal.writeInteractive(ansi.syncEnd);
341 }
342 terminal.close();
343 terminal = null;
344 }
285 buffer = "";345 buffer = "";
286 return;346 return;
287 }347 }
288348
289 const { columns, rows } = getSize();349 terminal ??= lockTerminal();
350
351 const { columns, rows } = terminal.getSize();
290 let newWidgetLines: string[] = [];352 let newWidgetLines: string[] = [];
291 let next = Infinity;353 let next = Infinity;
292 if (widgets[0]) {354 for (let w = 0, { length } = widgets; w < length; w += 1) {
293 for (let w = 0, { length } = widgets; w < length; w += 1) {355 const outText = UNWRAP(widgets[w]).format(lastFlush);
294 const outText = UNWRAP(widgets[w]).format(lastFlush);356 if (!outText) {
295 if (!outText) {357 widgets.splice(w, 1);
296 widgets.splice(w, 1);358 UNWRAP(internals.splice(w, 1)[0]).unsub?.();
297 UNWRAP(internals.splice(w, 1)[0]).unsub?.();359 w -= 1;
298 w -= 1;360 length -= 1;
299 length -= 1;361 continue;
300 continue;
301 }
302 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
303 if (rowsLeft === 1) break;
304 const lines = outText.split("\n").slice(0, rowsLeft);
305 newWidgetLines.push(
306 ...lines.map((line) => ansi.trimToWidth(line, columns - 1)),
307 );
308
309 next = Math.min(next, UNWRAP(internals[w]).frameTime);
310 }362 }
363 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
364 if (rowsLeft === 1) break;
365 const lines = outText.split("\n").slice(0, rowsLeft);
366 newWidgetLines.push(
367 ...lines.map((line) => ansi.trimToWidth(line, columns - 1)),
368 );
311369
312 newWidgetLines = newWidgetLines.slice(0, rows - 1);370 next = Math.min(next, UNWRAP(internals[w]).frameTime);
313 }371 }
314372 newWidgetLines = newWidgetLines.slice(0, rows - 1);
315 if (next < Infinity) redrawSoon(next);373 if (next < Infinity) redrawSoon(next);
316374
317 if (!newWidgetLines[0]) {375 if (!newWidgetLines[0]) {
318 if (lines.length) {376 if (lines.length > 0) {
319 let clearLinesTop = Math.min(377 terminal.writeInteractive(
320 lines.length,378 (hasSyncStart ? "" : ansi.syncStart) +
321 string.countNewlines(buffer),379 // clear the widget space
322 );380 (ansi.cursorUp(1) + ansi.clearFullLine)
323 writeInteractive(381 .repeat(lines.length) +
324 ansi.startOfLine +382 (partialLine ? ansi.cursorRestore : ""),
325 // skip up the shared widget space
326 ansi.cursorUp(lines.length) +
327 // clear the lines to contain `buffer`
328 (ansi.clearFullLine + ansi.startOfNextLine)
329 .repeat(clearLinesTop) +
330 ansi.cursorUp(clearLinesTop),
331 );383 );
384 hasSyncStart = true;
332 lines = [];385 lines = [];
333 }386 }
334 buffer && writeOutput(buffer);387 if (buffer) terminal.writeOutput(buffer);
335 buffer = "";388 buffer = "";
389 if (hasSyncStart) terminal.writeInteractive(ansi.syncEnd);
336 return;390 return;
337 }391 }
338392
339 if (buffer) {393 if (buffer) {
340 // do not perform diffing since the entire screen is moving down394 // when writing a buffer alongside widgets, the screen may look like this
341 // TODO: should lib/log handle wrapping?395 // > [existing log]
342 let clearLinesTop = Math.min(396 // > [optional partial line]
397 // > [widget line 1]
398 // > [widget line 2]
399 // > [widget line 3]
400 // > [cursor is start of this line]
401 //
402 // first, clear out the space where new lines are going to intersect
403 const createsPartialLine = !buffer.endsWith("\n");
404 // if more lines are buffered than there are widgets, only some are needed
405 const clearLinesTop = Math.min(
343 lines.length,406 lines.length,
344 string.countNewlines(buffer),407 string.countNewlines(buffer) +
408 (createsPartialLine ? 1 : 0) +
409 (partialLine ? -1 : 0),
345 );410 );
346 const oldLines = lines.slice(clearLinesTop);411 const oldLines = lines.slice(clearLinesTop);
347 writeInteractive(412 terminal.writeInteractive(
348 ansi.syncStart + (clearLinesTop > 0413 (hasSyncStart ? "" : ansi.syncStart) +
349 ? ansi.startOfLine +414 ((clearLinesTop > 0 || partialLine)
350 // skip up the shared widget space415 // clear the lines for buffer
351 ansi.cursorUp(lines.length - clearLinesTop + 1) +416 ? (clearLinesTop > 0
352 // clear the lines to contain `buffer`417 ? ansi.cursorUp(lines.length - clearLinesTop + 1) +
353 ansi.clearFullLine +418 ansi.clearFullLine +
354 (ansi.cursorUp(1) + ansi.clearFullLine)419 (ansi.cursorUp(1) + ansi.clearFullLine)
355 .repeat(clearLinesTop - 1)420 .repeat(clearLinesTop - 1)
356 : ""),421 : "") +
422 (partialLine ? ansi.cursorRestore : "")
423 : ""),
357 );424 );
425 partialLine = createsPartialLine;
358 // then write output lines on standard out426 // then write output lines on standard out
359 writeOutput(buffer);427 terminal.writeOutput(buffer);
360 writeInteractive(428 terminal.writeInteractive(
361 // the widget text429 // if a partial line is created, then the widgets
362 newWidgetLines.map((newLine, i) =>430 // have to go on the next line, to avoid breaking stdout,
363 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)431 // the newline gets emitted on the interactive out.
364 ? newLine + ansi.reset432 (createsPartialLine ? ansi.cursorSave + "\n" : "") +
365 : newLine) +433 // the widget text
366 // clear rest of line if needed434 newWidgetLines.map((newLine, i) =>
367 (oldLines[i] &&435 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
368 ansi.widthInTerminal(oldLines[i]) >436 ? newLine + ansi.reset
369 ansi.widthInTerminal(newLine)437 : newLine) +
370 ? ansi.clearToEndOfLine438 // clear rest of line if needed
371 : "") +439 (oldLines[i] &&
372 "\n"440 ansi.widthInTerminal(oldLines[i]) >
373 ).join("") + ansi.syncEnd,441 ansi.widthInTerminal(newLine)
442 ? ansi.clearToEndOfLine
443 : "") +
444 "\n"
445 ).join("") + ansi.syncEnd,
374 );446 );
375 } else {447 } else {
376 const clearLinesBottom = Math.min(448 const clearLinesBottom = Math.min(
377 lines.length,449 lines.length,
378 Math.max(0, lines.length - (newWidgetLines?.length ?? 0)),450 Math.max(0, lines.length - (newWidgetLines?.length ?? 0)),
379 );451 );
380 writeInteractive(452 terminal.writeInteractive(
381 ansi.syncStart + ansi.startOfLine +453 (hasSyncStart ? "" : ansi.syncStart) +
382 // clear the bottom lines454 // clear the bottom lines
383 (clearLinesBottom455 (clearLinesBottom
384 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine)456 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine)
...@@ -402,6 +474,7 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -402,6 +474,7 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
402 ansi.syncEnd,474 ansi.syncEnd,
403 );475 );
404 }476 }
477 hasSyncStart = false;
405 lines = newWidgetLines;478 lines = newWidgetLines;
406479
407 buffer = "";480 buffer = "";
...@@ -420,55 +493,87 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -420,55 +493,87 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
420 timer.then(redrawCallback);493 timer.then(redrawCallback);
421 }494 }
422495
423 function flushAndClear() {496 function flushAndClear(shortTermDrawLock: boolean) {
424 timer?.cancel();497 timer?.cancel();
425 timer = null;498 timer = null;
426 if (lines.length > 0) {499 if (lines.length > 0) {
427 // clear widgets500 UNWRAP(terminal).writeInteractive(
501 ansi.syncStart +
502 // clear the widget space
503 (ansi.cursorUp(1) + ansi.clearFullLine)
504 .repeat(lines.length) +
505 (partialLine ? ansi.cursorRestore : "") +
506 (shortTermDrawLock ? "" : ansi.syncEnd),
507 );
508 lines = [];
509 hasSyncStart = shortTermDrawLock;
510 }
511 if (buffer.length > 0) {
512 if (widgets.length === 0 && writeOutputTemporaryLock) {
513 writeOutputTemporaryLock(buffer);
514 } else {
515 terminal ??= lockTerminal();
516 terminal.writeOutput(buffer);
517 if (widgets.length === 0) {
518 terminal.close();
519 terminal = null;
520 }
521 }
522 buffer = "";
428 }523 }
429 if (buffer.length > 0) writeOutput(buffer), buffer = "";
430 }524 }
431525
432 return {526 return {
433 writeLine(chunk) {527 write(chunk) {
434 buffer += chunk + "\n";528 if (chunk) buffer += chunk, redrawSoon(0);
435 redrawSoon(0);
436 },529 },
437 getDrawLock() {530 getDrawLock(mode) {
438 if (locks === 0) flushAndClear();531 if (locks === 0) {
532 flushAndClear(mode === "short");
533 if (widgets.length > 0 && terminal) {
534 if (terminal.temporaryUnlock) {
535 tempUnlock = terminal.temporaryUnlock();
536 } else {
537 terminal.close();
538 terminal = null;
539 }
540 }
541 }
439 locks += 1;542 locks += 1;
440 return ts.defer(() => {543 return ts.defer(() => {
441 locks -= 1;544 locks -= 1;
442 if (locks === 0) {545 if (locks === 0) {
443 if (buffer.length > 0) writeOutput(buffer), buffer = "";546 tempUnlock?.();
444 // TODO: reinit widget547 tempUnlock = null;
548 if (buffer.length > 0 || widgets.length > 0) redrawSoon(0);
445 }549 }
446 });550 });
447 },551 },
448 startWidget(w) {552 startWidget(w) {
449 if (!widgets.includes(w)) {553 ASSERT(!widgets.includes(w), "Cannot start the same widget twice.");
450 const state: WidgetState = {554 const state: WidgetState = {
451 next: 0,555 next: 0,
452 unsub: null,556 unsub: null,
453 frameTime: 1000 / (w.fps ?? 0),557 frameTime: 1000 / (w.fps ?? 0),
454 };558 };
455 widgets.push(w);559 widgets.push(w);
456 internals.push(state);560 internals.push(state);
457 state.unsub = w.onChange?.(() => {561 state.unsub = w.onChange?.(() => {
458 state.next = 0;562 state.next = 0;
459 redrawSoon(0);
460 }) ?? null;
461 redrawSoon(0);563 redrawSoon(0);
462 }564 }) ?? null;
565 redrawSoon(0);
463 return ts.defer(() => {566 return ts.defer(() => {
464 const i = widgets.indexOf(w);567 const i = widgets.indexOf(w);
568 if (i === -1) return;
465 widgets.splice(i, 1);569 widgets.splice(i, 1);
466 UNWRAP(internals.splice(i, 1)[0]).unsub?.();570 UNWRAP(internals.splice(i, 1)[0]).unsub?.();
467 redrawSoon(0);571 redrawSoon(0);
468 });572 });
469 },573 },
470 cancel() {574 cancel() {
471 flushAndClear();575 flushAndClear(false);
576 widgets.splice(0, widgets.length);
472 },577 },
473 delay,578 delay,
474 now,579 now,
...@@ -500,7 +605,7 @@ let withinDispatch = false;...@@ -500,7 +605,7 @@ let withinDispatch = false;
500let withinStackCapture = false;605let withinStackCapture = false;
501606
502/** this class is an implementation detail */607/** this class is an implementation detail */
503const ScopeImpl = class Scope implements Scope {608const ScopeImpl = class Scope implements RootScope {
504 name: string | undefined;609 name: string | undefined;
505 // TODO: this abstraction implementation has low performance. making every610 // TODO: this abstraction implementation has low performance. making every
506 // scope define it's own dispatch is needed to correctly implement `tee`.611 // scope define it's own dispatch is needed to correctly implement `tee`.
...@@ -561,6 +666,23 @@ const ScopeImpl = class Scope implements Scope {...@@ -561,6 +666,23 @@ const ScopeImpl = class Scope implements Scope {
561 withinDispatch = false;666 withinDispatch = false;
562 };667 };
563668
669 write: (text: string) => void = (text) => {
670 let frames;
671 if (!withinStackCapture) {
672 withinStackCapture = true;
673 frames = stack.capture().slice(2);
674 withinStackCapture = false;
675 }
676 this.writeMessage({
677 level: "info",
678 scope: "",
679 newline: false,
680 text,
681 time: Date.now(),
682 stack: frames,
683 });
684 };
685
564 scoped(name: string): Scope {686 scoped(name: string): Scope {
565 const current = this.name;687 const current = this.name;
566 return new Scope(688 return new Scope(
...@@ -584,16 +706,17 @@ const globalWidgetHost = /* @__PURE__ */ (() => {...@@ -584,16 +706,17 @@ const globalWidgetHost = /* @__PURE__ */ (() => {
584 // return a no-op706 // return a no-op
585 let warned = false;707 let warned = false;
586 return {708 return {
587 writeLine: (line: string) => console.log(line),709 write: (line: string) => console.log(line),
588 getDrawLock: () => ts.defer(() => {}),710 getDrawLock: () => ts.defer(() => {}),
589 startWidget: (w: Widget) => {711 startWidget: (w: Widget) => {
590 if (!warned) {712 if (!warned) {
591 console.warn(713 console.warn(
592 '"@clo/lib/log.ts"\'s startWidget was called in an environment ' +714 '"@clo/lib/log.ts"\'s startWidget was called in an environment ' +
593 "that does not support the Node.js 'process' API. Widgets" +715 "that does not support the Node.js 'process' API. Widgets " +
594 "will not be visible.",716 "will not be visible.",
595 );717 );
596 }718 }
719 warned = true;
597 const close = w.onChange?.(() => {});720 const close = w.onChange?.(() => {});
598 return ts.defer(close ?? (() => {}));721 return ts.defer(close ?? (() => {}));
599 },722 },
...@@ -601,29 +724,76 @@ const globalWidgetHost = /* @__PURE__ */ (() => {...@@ -601,29 +724,76 @@ const globalWidgetHost = /* @__PURE__ */ (() => {
601 };724 };
602 }725 }
603 const widget = headlessWidgetHost({726 const widget = headlessWidgetHost({
604 writeOutput: (string) => process.stdout.write(string),727 lockTerminal() {
605 writeInteractive: (string) => process.stderr.write(string),728 const { stdout, stderr } = process;
729 let disposed = false;
730
731 function patch<T, A extends unknown[]>(
732 fn: (this: T, ...args: A) => void,
733 ) {
734 return function (this: T, ...args: A) {
735 using _ = disposed ? null : widget.getDrawLock();
736 fn.apply(this, args);
737 };
738 }
739
740 // patch calls to `process.std{out,err}`
741 // note: `pipe` uses managed calls to `write`, so this is plenty
742 const stdoutWrite = stdout.write;
743 const stderrWrite = stderr.write;
744 const stdoutEnd = stdout.end;
745 const stderrEnd = stderr.end;
746 const newStdoutWrite = stdout.write = patch(
747 stdoutWrite === node.builtin("stream")?.Writable.prototype.write
748 ? widget.write
749 : stdoutWrite,
750 );
751 const newStderrWrite = stderr.write = patch(stderrWrite);
752 const newStdoutEnd = stdout.end = patch(stdoutEnd);
753 const newStderrEnd = stderr.end = patch(stderrEnd);
754
755 // non-node runtimes will typically implement console in a way that
756 // doesn't use `node:process`, so it must also get patched
757 const console = globalThis
758 .console as unknown as Record<string, () => void>;
759 const restoreConsole: [string, old: () => void, patch: () => void][] = [];
760 for (const [key, old] of Object.entries(console)) {
761 if (typeof old !== "function") continue;
762 try {
763 const patched = console[key] = patch(old);
764 restoreConsole.push([key, old, patched]);
765 } catch { /* skip */ }
766 }
767
768 return {
769 writeOutput: (string) => stdoutWrite.call(stdout, string),
770 writeInteractive: (string) => stderrWrite.call(stderr, string),
771 getSize: () => process.stderr,
772 temporarilyUnlock() {
773 // no action needed
774 },
775 close() {
776 disposed = true;
777 // leave patches in place if something else tampered with it.
778 if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite;
779 if (stderr.write === newStderrWrite) stdout.write = stderrWrite;
780 if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd;
781 if (stderr.end === newStderrEnd) stdout.end = stderrEnd;
782 for (const [key, old, patched] of restoreConsole) {
783 if (console[key] === patched) console[key] = old;
784 }
785 },
786 };
787 },
788 writeOutputTemporaryLock(buffer: string) {
789 process.stdout.write(buffer);
790 },
606 now: () => performance.now(),791 now: () => performance.now(),
607 delay: async.delay,792 delay: async.delay,
608 getSize: () => process.stderr,
609 });793 });
610 process.addListener("beforeExit", () => widget.cancel());794 process.addListener("beforeExit", () => widget.cancel());
611 process.addListener("exit", () => widget.cancel());795 process.addListener("exit", () => widget.cancel());
612796
613 // Make sure the default `console` will not interweave with widgets
614 try {
615 const console = globalThis.console as unknown as Record<string, Function>;
616 for (const key of Object.keys(console)) {
617 const fn = console[key];
618 if (typeof fn === "function") {
619 console[key] = function (...args: unknown[]) {
620 using _ = getDrawLock();
621 fn.apply(this, args);
622 };
623 }
624 }
625 } catch {}
626
627 return widget;797 return widget;
628})();798})();
629799
...@@ -635,7 +805,7 @@ const levelToAnsi: Record<Message["level"], string> = {...@@ -635,7 +805,7 @@ const levelToAnsi: Record<Message["level"], string> = {
635};805};
636806
637let globalMessageFormatFunction: MessageFormatFunction = (807let globalMessageFormatFunction: MessageFormatFunction = (
638 { level, scope, text },808 { level, scope, text, newline },
639 colors,809 colors,
640) => {810) => {
641 if (!text) return "";811 if (!text) return "";
...@@ -648,7 +818,7 @@ let globalMessageFormatFunction: MessageFormatFunction = (...@@ -648,7 +818,7 @@ let globalMessageFormatFunction: MessageFormatFunction = (
648 : scope818 : scope
649 ? `${level}(${scope}): `819 ? `${level}(${scope}): `
650 : `${level}: `;820 : `${level}: `;
651 return prefix + text;821 return prefix + text + (newline !== false ? "\n" : "");
652};822};
653let globalOutputFunction!: DispatchFunction;823let globalOutputFunction!: DispatchFunction;
654const globalLog = /* @__PURE__ */ (() => {824const globalLog = /* @__PURE__ */ (() => {
...@@ -656,7 +826,7 @@ const globalLog = /* @__PURE__ */ (() => {...@@ -656,7 +826,7 @@ const globalLog = /* @__PURE__ */ (() => {
656 globalOutputFunction = node.process826 globalOutputFunction = node.process
657 // In Node.js, coordinate with the widget host827 // In Node.js, coordinate with the widget host
658 ? (message) => {828 ? (message) => {
659 globalWidgetHost.writeLine(globalMessageFormatFunction(message, colors));829 globalWidgetHost.write(globalMessageFormatFunction(message, colors));
660 }830 }
661 // Otherwise, forward to `console`831 // Otherwise, forward to `console`
662 : (m) => {832 : (m) => {
...@@ -673,6 +843,13 @@ const globalLog = /* @__PURE__ */ (() => {...@@ -673,6 +843,13 @@ const globalLog = /* @__PURE__ */ (() => {
673})();843})();
674844
675export type DispatchFunction = (message: Message) => void;845export type DispatchFunction = (message: Message) => void;
846/**
847 * agnostic to the backend. should be able to format `newline: false`
848 * messages without a trailing newline, but not all backends may
849 * support this.
850 *
851 * TODO: change this to a `write`+`flush` pattern?
852 */
676export type MessageFormatFunction = (853export type MessageFormatFunction = (
677 message: Message,854 message: Message,
678 colors: boolean,855 colors: boolean,
...@@ -684,4 +861,4 @@ import * as node from "./node.ts";...@@ -684,4 +861,4 @@ import * as node from "./node.ts";
684import * as stack from "./log/stack.ts";861import * as stack from "./log/stack.ts";
685import * as string from "./string.ts";862import * as string from "./string.ts";
686import * as ts from "./ts.ts";863import * as ts from "./ts.ts";
687import { UNWRAP } from "./assert.ts";864import { ASSERT, UNWRAP } from "./assert.ts";
lib/node.ts+8
...@@ -27,6 +27,7 @@ interface Tty {...@@ -27,6 +27,7 @@ interface Tty {
27 columns: number;27 columns: number;
28 rows: number;28 rows: number;
29 addListener(event: string, callback: () => void): this;29 addListener(event: string, callback: () => void): this;
30 end(text?: string | Uint8Array): void;
30 write(text: string | Uint8Array): void;31 write(text: string | Uint8Array): void;
31}32}
3233
...@@ -54,6 +55,13 @@ interface Builtins {...@@ -54,6 +55,13 @@ interface Builtins {
54 ...args: unknown[]55 ...args: unknown[]
55 ): string;56 ): string;
56 };57 };
58 "stream": {
59 Writable: {
60 prototype: {
61 write: (value: string) => boolean;
62 };
63 };
64 };
57}65}
58/**66/**
59 * Subset of Node.js binding types67 * Subset of Node.js binding types
lib/progress.test.ts+8-8
...@@ -4,13 +4,13 @@ test.skip("trivial end-to-end example", () => {...@@ -4,13 +4,13 @@ test.skip("trivial end-to-end example", () => {
4 const screen = new testing.MockScreen(); // create a mock terminal4 const screen = new testing.MockScreen(); // create a mock terminal
5 const root = new progress.Root(screen); // sync with mock timers5 const root = new progress.Root(screen); // sync with mock timers
6 progress.attachToScreen(root, screen); // attach `root` to the `screen`6 progress.attachToScreen(root, screen); // attach `root` to the `screen`
7 assert.equal(screen.waitCalls.length, 0); // attaching does not render7 assert.equal(screen.timers.entries.length, 0); // attaching does not render
88
9 const a = root.start("hello");9 const a = root.start("hello");
10 const b = root.start("cats");10 const b = root.start("cats");
11 const _ = b.start("meow");11 const _ = b.start("meow");
1212
13 assert.equal(screen.waitCalls.length, 1);13 assert.equal(screen.timers.entries.length, 1);
1414
15 screen.expectFrame(1, { merged: "" }); // debounce15 screen.expectFrame(1, { merged: "" }); // debounce
16 // TODO: why the full resets?16 // TODO: why the full resets?
...@@ -43,8 +43,8 @@ test.skip("trivial end-to-end example", () => {...@@ -43,8 +43,8 @@ test.skip("trivial end-to-end example", () => {
43 });43 });
44});44});
4545
46describe("encodeEventStream", async (t) => {46describe("encodeEventStream", (t) => {
47 await test("simple", async () => {47 test("simple", async () => {
48 const root = new progress.Root(); // sync with mock timers48 const root = new progress.Root(); // sync with mock timers
4949
50 const a = root.start("hello");50 const a = root.start("hello");
...@@ -77,14 +77,14 @@ describe("encodeEventStream", async (t) => {...@@ -77,14 +77,14 @@ describe("encodeEventStream", async (t) => {
77});77});
7878
79// ## value formatters79// ## value formatters
80test("slashValueFormatter", ({ mock }) => {80test("slashValueFormatter", () => {
81 const fn = mock.fn((arg: number) => `<${arg}>`);81 const fn = vi.fn((arg: number) => `<${arg}>`);
8282
83 const fmt = progress.slashValueFormatter(fn);83 const fmt = progress.slashValueFormatter(fn);
84 assert.equal(fmt(250, 0), "<250>");84 assert.equal(fmt(250, 0), "<250>");
85 assert.equal(fn.mock.callCount(), 1);85 assert.equal(fn.mock.calls.length, 1);
86 assert.equal(fmt(250, 450), "<250>/<450>");86 assert.equal(fmt(250, 450), "<250>/<450>");
87 assert.equal(fn.mock.callCount(), 3);87 assert.equal(fn.mock.calls.length, 3);
88});88});
89test("bytesValueFormatter", () => {89test("bytesValueFormatter", () => {
90 assert.equal(progress.bytesValueFormatter(250, 0), "250B");90 assert.equal(progress.bytesValueFormatter(250, 0), "250B");
lib/progress.ts+6-5
...@@ -753,9 +753,9 @@ export function formatUnicodeBar(progress: number, width: number): string {...@@ -753,9 +753,9 @@ export function formatUnicodeBar(progress: number, width: number): string {
753 */753 */
754export function attachToScreen(754export function attachToScreen(
755 root: Root,755 root: Root,
756 { writeLine, startWidget }: Pick<756 { write, startWidget }: Pick<
757 log.HeadlessWidgetHost,757 log.HeadlessWidgetHost,
758 "writeLine" | "startWidget"758 "write" | "startWidget"
759 >,759 >,
760): ts.Dispose {760): ts.Dispose {
761 const stack = new DisposableStack();761 const stack = new DisposableStack();
...@@ -774,7 +774,7 @@ export function attachToScreen(...@@ -774,7 +774,7 @@ export function attachToScreen(
774 stack.use(774 stack.use(
775 root.on(775 root.on(
776 "node-detached-log",776 "node-detached-log",
777 (msg) => writeLine(log.formatMessage(msg, true)),777 (msg) => write(log.formatMessage(msg, true)),
778 ),778 ),
779 );779 );
780 stack.use(root.on("node-end", (node) => {780 stack.use(root.on("node-end", (node) => {
...@@ -785,8 +785,8 @@ export function attachToScreen(...@@ -785,8 +785,8 @@ export function attachToScreen(
785 if (logs.length > 0) {785 if (logs.length > 0) {
786 const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`;786 const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`;
787 const header = `[${count} from ${title}]`;787 const header = `[${count} from ${title}]`;
788 writeLine(ansi.style(ansi.fgBrightBlack, header));788 write(ansi.style(ansi.fgBrightBlack, header) + "\n");
789 writeLine(logs.map((msg) => log.formatMessage(msg, true)).join("\n"));789 write(logs.map((msg) => log.formatMessage(msg, true)).join(""));
790 }790 }
791 }));791 }));
792792
...@@ -1440,6 +1440,7 @@ function writeStreamEvent(...@@ -1440,6 +1440,7 @@ function writeStreamEvent(
1440 if (msg.custom !== undefined) {1440 if (msg.custom !== undefined) {
1441 w.stringWithLength(JSON.stringify(msg.custom));1441 w.stringWithLength(JSON.stringify(msg.custom));
1442 }1442 }
1443 // TODO: serialize newline attribute
1443 }1444 }
1444 }1445 }
1445 if (childrenSet !== undefined) {1446 if (childrenSet !== undefined) {
lib/testing.ts+76-61
...@@ -111,7 +111,7 @@ export class SyncPromise<T> implements Promise<T> {...@@ -111,7 +111,7 @@ export class SyncPromise<T> implements Promise<T> {
111111
112export class FakeTimers {112export class FakeTimers {
113 time = 0;113 time = 0;
114 timers: Array<{114 entries: Array<{
115 duration: number;115 duration: number;
116 resolve: () => void;116 resolve: () => void;
117 src: stack.Frame[];117 src: stack.Frame[];
...@@ -125,7 +125,8 @@ export class FakeTimers {...@@ -125,7 +125,8 @@ export class FakeTimers {
125 const src = stack.capture(2);125 const src = stack.capture(2);
126 return async.makeCancelable(126 return async.makeCancelable(
127 new SyncPromise((resolve) => {127 new SyncPromise((resolve) => {
128 this.timers.push({128 ASSERT(this.entries.length === 0);
129 this.entries.push({
129 duration: ms - this.waitTime,130 duration: ms - this.waitTime,
130 resolve,131 resolve,
131 src,132 src,
...@@ -133,7 +134,8 @@ export class FakeTimers {...@@ -133,7 +134,8 @@ export class FakeTimers {
133 this.waitTime = ms;134 this.waitTime = ms;
134 }),135 }),
135 () => {136 () => {
136 throw new Error("TODO");137 ASSERT(this.entries.length === 1);
138 this.entries.length = 0;
137 },139 },
138 );140 );
139 };141 };
...@@ -153,95 +155,105 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {...@@ -153,95 +155,105 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {
153 columns = 80;155 columns = 80;
154 rows = 33;156 rows = 33;
155157
156 time = 0;
157
158 stdout: string = "";158 stdout: string = "";
159 stderr: string = "";159 stderr: string = "";
160 out: string = "";160 out: string = "";
161 writeCalls = 0;161 writeCalls = 0;
162162
163 waitCalls: Array<{163 timers = new FakeTimers();
164 duration: number;
165 resolve: () => void;
166 src: stack.Frame[];
167 }> = [];
168 waitTime = 0;
169164
170 writeLine: log.HeadlessWidgetHost["writeLine"];165 write: log.HeadlessWidgetHost["write"];
171 getDrawLock: log.HeadlessWidgetHost["getDrawLock"];166 getDrawLock: log.HeadlessWidgetHost["getDrawLock"];
172 startWidget: log.HeadlessWidgetHost["startWidget"];167 startWidget: log.HeadlessWidgetHost["startWidget"];
173 cancel: log.HeadlessWidgetHost["cancel"];
174 delay: log.HeadlessWidgetHost["delay"];168 delay: log.HeadlessWidgetHost["delay"];
175 now: log.HeadlessWidgetHost["now"];169 now: log.HeadlessWidgetHost["now"];
176170
171 hasTerminalLock: null | "locked" | "temporary-unlock" = null;
172
177 static sync(text: string[]): string {173 static sync(text: string[]): string {
178 return ansi.syncStart + text.join("") + ansi.syncEnd;174 return ansi.syncStart + text.join("") + ansi.syncEnd;
179 }175 }
180176
181 constructor() {177 constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) {
182 const host = log.headlessWidgetHost({178 const host = log.headlessWidgetHost({
183 writeInteractive: (text) => {179 lockTerminal: () => {
184 this.stderr += text;180 ASSERT(!this.hasTerminalLock);
185 this.out += text;181 this.hasTerminalLock = "locked";
186 this.writeCalls += 1;182 return {
187 },183 writeInteractive: (text) => {
188 writeOutput: (text) => {184 this.stderr += text;
189 this.stdout += text;185 this.out += text;
190 this.out += text;186 this.writeCalls += 1;
191 this.writeCalls += 1;
192 },
193 now: () => {
194 return this.time;
195 },
196 delay: (ms) => {
197 const src = stack.capture(2);
198 return async.makeCancelable(
199 new SyncPromise((resolve) => {
200 this.waitCalls.push({
201 duration: ms - this.waitTime,
202 resolve,
203 src,
204 });
205 this.waitTime = ms;
206 }),
207 () => {
208 throw new Error("TODO");
209 },187 },
210 );188 writeOutput: (text) => {
211 },189 this.stdout += text;
212 getSize: () => {190 this.out += text;
213 return this;191 this.writeCalls += 1;
192 },
193 getSize: () => {
194 return this;
195 },
196 temporaryUnlock: temporaryUnlocking
197 ? () => {
198 ASSERT(this.hasTerminalLock === "locked");
199 this.hasTerminalLock = "temporary-unlock";
200 return () => {
201 this.hasTerminalLock = "locked";
202 };
203 }
204 : undefined,
205 close: () => {
206 ASSERT(this.hasTerminalLock === "locked");
207 this.hasTerminalLock = null;
208 },
209 };
214 },210 },
211 now: this.timers.now,
212 delay: this.timers.delay,
215 });213 });
216 this.writeLine = host.writeLine;214 this.write = host.write;
217 this.getDrawLock = host.getDrawLock;215 this.getDrawLock = host.getDrawLock;
218 this.startWidget = host.startWidget;216 this.startWidget = host.startWidget;
219 this.cancel = host.cancel;
220 this.delay = host.delay;217 this.delay = host.delay;
221 this.now = host.now;218 this.now = host.now;
222 }219 }
223220
224 expectFrame(ms: number, { stdout, stderr, merged: out }: {221 expectNone() {
222 ASSERT(this.timers.entries.length === 0);
223 }
224
225 expectWithoutConsume(ms: number) {
226 ASSERT(UNWRAP(this.timers.entries[0]).duration === 0);
227 }
228
229 expectFrame(ms: number | null, { stdout, stderr, merged: out }: {
225 stdout?: string;230 stdout?: string;
226 stderr?: string;231 stderr?: string;
227 merged?: string;232 merged?: string;
228 }) {233 }) {
229 const wait = UNWRAP(this.waitCalls.shift(), "no call to MockScreen.delay");234 if (ms != null) {
230 ASSERT(235 const wait = UNWRAP(
231 ms === wait.duration,236 this.timers.entries.shift(),
232 `expected ${ms}ms to pass, got ${wait.duration}, from:\n${237 "no call to MockScreen.delay",
233 wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n")238 );
234 }`,239 ASSERT(
235 );240 ms === wait.duration,
241 `expected ${ms}ms to pass, got ${wait.duration}, from:\n${
242 wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n")
243 }`,
244 );
236245
237 this.time += wait.duration;246 this.timers.time += wait.duration;
238 this.waitTime = 0;247 this.timers.waitTime = 0;
239 wait.resolve();248 wait.resolve();
249 } else {
250 ASSERT(this.timers.entries.length === 0);
251 }
240252
241 ASSERT(253 ASSERT(
242 out == null || this.out === out,254 out == null || this.out === out,
243 () =>255 () =>
244 `interweved out does not match\n` +256 `merged out does not match\n` +
245 `expected: ${ansi.debugAnsi(out ?? "")}\n` +257 `expected: ${ansi.debugAnsi(out ?? "")}\n` +
246 `actual: ${ansi.debugAnsi(this.out)}\n`,258 `actual: ${ansi.debugAnsi(this.out)}\n`,
247 );259 );
...@@ -265,9 +277,12 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {...@@ -265,9 +277,12 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {
265 }277 }
266278
267 [Symbol.dispose]() {279 [Symbol.dispose]() {
268 ASSERT(this.waitCalls.length === 0, "there is a pending write!");280 this.cancel();
269 ASSERT(!this.stdout, "unread standard out" + this.stdout);281 }
270 ASSERT(!this.stderr, "unread interactive out" + this.stderr);282 cancel() {
283 ASSERT(this.timers.entries.length === 0, "there is a pending write!");
284 ASSERT(!this.stdout, "unread standard out: " + this.stdout);
285 ASSERT(!this.stderr, "unread interactive out: " + this.stderr);
271 }286 }
272}287}
273288