authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-09-27 17:27:56-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:47-07:00
log17cea8e03404d3146f5a90c020abae96f4e142fe
tree69d8056c334e145546bc008c76c3c04367742369
parentc5236cd3ee2b76999ae6101cd189cdaac23ce0cf
signature Commit is signed but in an unrecognized format.

fix(lib/log): fix widget cursor positioning

by using the incredible pattern of headless software, amazing tests can be written with very little effort. the entire mock for the screen is only 97 lines long, and with an ansi debugger, mistakes are extremely easy to debug. example of one of the tests running on the old `lib/log/headless.ts`: expected: ${syncStart}\rline one 0\nline two\n${syncEnd} actual: ${syncStart}\rline one 0${clearToEndOfLine}\nline two...

3 files changed, 227 insertions(+), 32 deletions(-)

lib/log.test.ts created+161
......@@ -0,0 +1,161 @@
1import { ASSERT, UNWRAP } from "lib/assert.ts";
2import { test } from "node:test";
3import * as headless from "lib/log/headless.ts";
4
5class TestWidgetHost implements headless.WidgetHost {
6 columns = 80;
7 rows = 33;
8
9 now = 0;
10
11 stdout: string = "";
12 stderr: string = "";
13 out: string = "";
14 writeCalls = 0;
15
16 waitDuration = 0;
17 waiter: (() => void) | null = null;
18
19 writeLine: headless.WidgetHost["writeLine"];
20 getDrawLock: headless.WidgetHost["getDrawLock"];
21 startWidget: headless.WidgetHost["startWidget"];
22 cancel: headless.WidgetHost["cancel"];
23
24 constructor() {
25 const host = headless.widgetHost({
26 writeInteractive: (text) => {
27 this.stderr += text;
28 this.out += text;
29 this.writeCalls += 1;
30 },
31 writeOutput: (text) => {
32 this.stdout += text;
33 this.out += text;
34 this.writeCalls += 1;
35 },
36 now: () => {
37 return this.now;
38 },
39 wait: (ms, cb) => {
40 this.waitDuration = ms;
41 this.waiter = cb;
42 return () => this.waiter = null;
43 },
44 getSize: () => {
45 return this;
46 },
47 });
48 this.writeLine = host.writeLine;
49 this.getDrawLock = host.getDrawLock;
50 this.startWidget = host.startWidget;
51 this.cancel = host.cancel;
52 }
53
54 expectFrame(ms: number, { stdout, stderr, out }: {
55 stdout?: string;
56 stderr?: string;
57 out?: string;
58 }) {
59 const { waitDuration } = this;
60 ASSERT(
61 ms === waitDuration,
62 `expected ${ms}ms to pass, only ${waitDuration}`,
63 );
64
65 const callback = UNWRAP(this.waiter, "no pending frame");
66 this.waiter = null;
67 this.now += this.waitDuration;
68 callback();
69
70 ASSERT(
71 out == null || this.out === out,
72 () =>
73 `interweved out does not match\n` +
74 `expected: ${ansi.debugAnsi(out ?? "")}\n` +
75 `actual: ${ansi.debugAnsi(this.out)}\n`,
76 );
77 ASSERT(
78 stdout == null || this.stdout === stdout,
79 () =>
80 `standard out does not match\n` +
81 `expected: ${ansi.debugAnsi(stdout ?? "")}\n` +
82 `actual: ${ansi.debugAnsi(this.stdout)}\n`,
83 );
84 ASSERT(
85 stderr == null || this.stderr === stderr,
86 () =>
87 `interactive out does not match\n` +
88 `expected: ${ansi.debugAnsi(stderr ?? "")}\n` +
89 `actual: ${ansi.debugAnsi(this.stderr)}\n`,
90 );
91 this.stdout = "";
92 this.stderr = "";
93 this.out = "";
94 }
95
96 [Symbol.dispose]() {
97 ASSERT(!this.waiter, "there is a pending write!");
98 ASSERT(!this.stdout, "unread standard out" + this.stdout);
99 ASSERT(!this.stderr, "unread interactive out" + this.stderr);
100 }
101}
102
103test("writeLine buffers", () => {
104 using host = new TestWidgetHost();
105 host.writeLine("hello world");
106 host.writeLine("and so on");
107 host.expectFrame(0, {
108 stdout: "hello world\nand so on\n",
109 });
110 host.writeLine("more logs");
111 host.expectFrame(0, {
112 stdout: "more logs\n",
113 });
114});
115
116test("widget with interweaving logs", () => {
117 const host = new TestWidgetHost();
118
119 const _ = host.startWidget({
120 format: (now) => `line one ${now}\nline two`,
121 });
122
123 host.expectFrame(0, {
124 stderr: sync([
125 ansi.startOfLine,
126 "line one 0\nline two\n",
127 ]),
128 });
129
130 host.writeLine("info: line of log");
131 host.expectFrame(0, {
132 out: sync([
133 ansi.startOfLine,
134 ansi.cursorUp(2),
135 ansi.clearFullLine,
136 "info: line of log\n",
137 "line one 0\nline two\n",
138 ]),
139 });
140
141 host.writeLine("warn: line of log");
142 host.writeLine("error: line of log");
143 host.expectFrame(0, {
144 out: sync([
145 ansi.startOfLine,
146 ansi.cursorUp(1),
147 ansi.clearFullLine,
148 ansi.cursorUp(1),
149 ansi.clearFullLine,
150 "warn: line of log\n",
151 "error: line of log\n",
152 "line one 0\nline two\n",
153 ]),
154 });
155});
156
157function sync(text: string[]) {
158 return ansi.syncStart + text.join("") + ansi.syncEnd;
159}
160
161import * as ansi from "lib/log/ansi.ts";
lib/log/ansi.ts+30-4
......@@ -6,7 +6,7 @@ export const fgRed: string = `\x1b[31m`;
66export const fgGreen: string = `\x1b[32m`;
77export const fgYellow: string = `\x1b[33m`;
88export const fgBlue: string = `\x1b[34m`;
9export const fgPurple: string = `\x1b[35m`;
9export const fgMagenta: string = `\x1b[35m`;
1010export const fgCyan: string = `\x1b[36m`;
1111export const fgWhite: string = `\x1b[37m`;
1212export const fgBrightBlack: string = `\x1b[90m`;
......@@ -14,7 +14,7 @@ export const fgBrightRed: string = `\x1b[91m`;
1414export const fgBrightGreen: string = `\x1b[92m`;
1515export const fgBrightYellow: string = `\x1b[93m`;
1616export const fgBrightBlue: string = `\x1b[94m`;
17export const fgBrightPurple: string = `\x1b[95m`;
17export const fgBrightMagenta: string = `\x1b[95m`;
1818export const fgBrightCyan: string = `\x1b[96m`;
1919export const fgBrightWhite: string = `\x1b[97m`;
2020export function fg256(n: number): string {
......@@ -30,7 +30,7 @@ export const bgRed: string = `\x1b[41m`;
3030export const bgGreen: string = `\x1b[42m`;
3131export const bgYellow: string = `\x1b[43m`;
3232export const bgBlue: string = `\x1b[44m`;
33export const bgPurple: string = `\x1b[45m`;
33export const bgMagenta: string = `\x1b[45m`;
3434export const bgCyan: string = `\x1b[46m`;
3535export const bgWhite: string = `\x1b[47m`;
3636export const bgBrightBlack: string = `\x1b[100m`;
......@@ -38,7 +38,7 @@ export const bgBrightRed: string = `\x1b[101m`;
3838export const bgBrightGreen: string = `\x1b[102m`;
3939export const bgBrightYellow: string = `\x1b[103m`;
4040export const bgBrightBlue: string = `\x1b[104m`;
41export const bgBrightPurple: string = `\x1b[105m`;
41export const bgBrightMagenta: string = `\x1b[105m`;
4242export const bgBrightCyan: string = `\x1b[106m`;
4343export const bgBrightWhite: string = `\x1b[107m`;
4444export function bg256(n: number): string {
......@@ -92,3 +92,29 @@ export function mergeStyles(code: string): string {
9292 }
9393 return `\x1b[${styles.join(";")}m`;
9494}
95
96export function debugAnsi(text: string): string {
97 // deno-fmt-ignore
98 const verbaitim: (keyof typeof self)[] = [
99 "bgReset", "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta",
100 "bgCyan", "bgWhite", "bgBrightBlack", "bgBrightRed", "bgBrightGreen",
101 "bgBrightYellow", "bgBrightBlue", "bgBrightMagenta", "bgBrightCyan",
102 "bgBrightWhite", "fgReset", "fgBlack", "fgRed", "fgGreen", "fgYellow",
103 "fgBlue", "fgMagenta", "fgCyan", "fgWhite", "fgBrightBlack", "fgBrightRed",
104 "fgBrightGreen", "fgBrightYellow", "fgBrightBlue", "fgBrightMagenta",
105 "fgBrightCyan", "fgBrightWhite", "clearToEndOfLine", "clearToStartOfLine",
106 "clearFullLine", "syncStart", "syncEnd"
107 ];
108 for (const key of verbaitim) {
109 text = text.replaceAll(
110 String(self[key]),
111 style(key.includes("cursor") ? fgBlue : fgCyan, `\${${key}}`),
112 );
113 }
114 text = text.replace(/\x1b\[(\d+)A/g, style(fgYellow, "$${cursorUp($1)}"));
115 text = text.replace(/\n/g, style(fgRed, "\\n"));
116 text = text.replace(/\r/g, style(fgMagenta, "\\r"));
117 return text;
118}
119
120import * as self from "./ansi.ts";
lib/log/headless.ts+36-28
......@@ -85,10 +85,10 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
8585 let next = Infinity;
8686 if (widgets[0]) {
8787 for (let w = 0, { length } = widgets; w < length; w += 1) {
88 const outText = widgets[w].format(lastFlush, false);
88 const outText = UNWRAP(widgets[w]).format(lastFlush, false);
8989 if (!outText) {
9090 widgets.splice(w, 1);
91 internals.splice(w, 1)[0].unsub?.();
91 UNWRAP(internals.splice(w, 1)[0]).unsub?.();
9292 w -= 1;
9393 length -= 1;
9494 continue;
......@@ -100,7 +100,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
100100 ...lines.map((line) => string.trimToWidth(line, columns - 1)),
101101 );
102102
103 next = Math.min(next, internals[w].frameTime);
103 next = Math.min(next, UNWRAP(internals[w]).frameTime);
104104 }
105105
106106 newWidgetLines = newWidgetLines.slice(0, rows - 1);
......@@ -137,31 +137,32 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
137137 lines.length,
138138 string.countNewlines(buffer),
139139 );
140 let clearLinesBottom = Math.min(
141 lines.length - clearLinesTop,
142 Math.max(0, lines.length - (newWidgetLines?.length ?? 0)),
143 );
140 const oldLines = lines.slice(clearLinesTop);
144141 writeInteractive(
145142 ansi.syncStart + ansi.startOfLine +
146 // clear the bottom lines
147 (clearLinesBottom
148 ? ansi.cursorUp(1) +
149 (ansi.clearToEndOfLine + ansi.cursorUp(1))
150 .repeat(clearLinesBottom)
151 : "") +
152143 // skip up the shared widget space
153 ansi.cursorUp(lines.length - clearLinesBottom) +
144 ansi.cursorUp(lines.length - clearLinesTop + 1) +
154145 // clear the lines to contain `buffer`
155 (ansi.clearFullLine + ansi.startOfNextLine)
156 .repeat(clearLinesTop) +
157 ansi.cursorUp(clearLinesTop),
146 ansi.clearFullLine +
147 (ansi.cursorUp(1) + ansi.clearFullLine)
148 .repeat(clearLinesTop - 1),
158149 );
159150 // then write output lines on standard out
160151 writeOutput(buffer);
161152 writeInteractive(
162153 // the widget text
163 newWidgetLines.join("\n") + "\n" +
164 ansi.syncEnd,
154 newWidgetLines.map((newLine, i) =>
155 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
156 ? newLine + ansi.reset
157 : newLine) +
158 // clear rest of line if needed
159 (oldLines[i] &&
160 string.widthInTerminal(oldLines[i]) >
161 string.widthInTerminal(newLine)
162 ? ansi.clearToEndOfLine
163 : "") +
164 "\n"
165 ).join("") + ansi.syncEnd,
165166 );
166167 } else {
167168 const clearLinesBottom = Math.min(
......@@ -178,11 +179,17 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
178179 // skip up the widget space
179180 ansi.cursorUp(lines.length - clearLinesBottom) +
180181 // the widget text
181 newWidgetLines.map((x) =>
182 (x.includes("\x1b") && !x.endsWith(ansi.reset)
183 ? x + ansi.reset
184 : x) +
185 ansi.clearToEndOfLine + "\n"
182 newWidgetLines.map((newLine, i) =>
183 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
184 ? newLine + ansi.reset
185 : newLine) +
186 // clear rest of line if needed
187 (lines[i] &&
188 string.widthInTerminal(lines[i]) >
189 string.widthInTerminal(newLine)
190 ? ansi.clearToEndOfLine
191 : "") +
192 "\n"
186193 ).join("") +
187194 ansi.syncEnd,
188195 );
......@@ -247,7 +254,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
247254 return ts.defer(() => {
248255 const i = widgets.indexOf(w);
249256 widgets.splice(i, 1);
250 internals.splice(i, 1)[0].unsub?.();
257 UNWRAP(internals.splice(i, 1)[0]).unsub?.();
251258 redrawSoon(0);
252259 });
253260 },
......@@ -308,8 +315,9 @@ export function logger(env: LoggerOptions): log.Scope {
308315
309316type Level = 0 | 1 | 2;
310317
311import * as ansi from "./ansi.ts";
312import * as string from "../string.ts";
313import * as ts from "../ts.ts";
318import * as ansi from "lib/log/ansi.ts";
319import * as string from "lib/string.ts";
320import * as ts from "lib/ts.ts";
314321import * as util from "node:util";
322import { UNWRAP } from "lib/assert.ts";
315323import type * as log from "../log.ts";