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`;...@@ -6,7 +6,7 @@ export const fgRed: string = `\x1b[31m`;
6export const fgGreen: string = `\x1b[32m`;6export const fgGreen: string = `\x1b[32m`;
7export const fgYellow: string = `\x1b[33m`;7export const fgYellow: string = `\x1b[33m`;
8export const fgBlue: string = `\x1b[34m`;8export const fgBlue: string = `\x1b[34m`;
9export const fgPurple: string = `\x1b[35m`;9export const fgMagenta: string = `\x1b[35m`;
10export const fgCyan: string = `\x1b[36m`;10export const fgCyan: string = `\x1b[36m`;
11export const fgWhite: string = `\x1b[37m`;11export const fgWhite: string = `\x1b[37m`;
12export const fgBrightBlack: string = `\x1b[90m`;12export const fgBrightBlack: string = `\x1b[90m`;
...@@ -14,7 +14,7 @@ export const fgBrightRed: string = `\x1b[91m`;...@@ -14,7 +14,7 @@ export const fgBrightRed: string = `\x1b[91m`;
14export const fgBrightGreen: string = `\x1b[92m`;14export const fgBrightGreen: string = `\x1b[92m`;
15export const fgBrightYellow: string = `\x1b[93m`;15export const fgBrightYellow: string = `\x1b[93m`;
16export const fgBrightBlue: string = `\x1b[94m`;16export const fgBrightBlue: string = `\x1b[94m`;
17export const fgBrightPurple: string = `\x1b[95m`;17export const fgBrightMagenta: string = `\x1b[95m`;
18export const fgBrightCyan: string = `\x1b[96m`;18export const fgBrightCyan: string = `\x1b[96m`;
19export const fgBrightWhite: string = `\x1b[97m`;19export const fgBrightWhite: string = `\x1b[97m`;
20export function fg256(n: number): string {20export function fg256(n: number): string {
...@@ -30,7 +30,7 @@ export const bgRed: string = `\x1b[41m`;...@@ -30,7 +30,7 @@ export const bgRed: string = `\x1b[41m`;
30export const bgGreen: string = `\x1b[42m`;30export const bgGreen: string = `\x1b[42m`;
31export const bgYellow: string = `\x1b[43m`;31export const bgYellow: string = `\x1b[43m`;
32export const bgBlue: string = `\x1b[44m`;32export const bgBlue: string = `\x1b[44m`;
33export const bgPurple: string = `\x1b[45m`;33export const bgMagenta: string = `\x1b[45m`;
34export const bgCyan: string = `\x1b[46m`;34export const bgCyan: string = `\x1b[46m`;
35export const bgWhite: string = `\x1b[47m`;35export const bgWhite: string = `\x1b[47m`;
36export const bgBrightBlack: string = `\x1b[100m`;36export const bgBrightBlack: string = `\x1b[100m`;
...@@ -38,7 +38,7 @@ export const bgBrightRed: string = `\x1b[101m`;...@@ -38,7 +38,7 @@ export const bgBrightRed: string = `\x1b[101m`;
38export const bgBrightGreen: string = `\x1b[102m`;38export const bgBrightGreen: string = `\x1b[102m`;
39export const bgBrightYellow: string = `\x1b[103m`;39export const bgBrightYellow: string = `\x1b[103m`;
40export const bgBrightBlue: string = `\x1b[104m`;40export const bgBrightBlue: string = `\x1b[104m`;
41export const bgBrightPurple: string = `\x1b[105m`;41export const bgBrightMagenta: string = `\x1b[105m`;
42export const bgBrightCyan: string = `\x1b[106m`;42export const bgBrightCyan: string = `\x1b[106m`;
43export const bgBrightWhite: string = `\x1b[107m`;43export const bgBrightWhite: string = `\x1b[107m`;
44export function bg256(n: number): string {44export function bg256(n: number): string {
...@@ -92,3 +92,29 @@ export function mergeStyles(code: string): string {...@@ -92,3 +92,29 @@ export function mergeStyles(code: string): string {
92 }92 }
93 return `\x1b[${styles.join(";")}m`;93 return `\x1b[${styles.join(";")}m`;
94}94}
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 {...@@ -85,10 +85,10 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
85 let next = Infinity;85 let next = Infinity;
86 if (widgets[0]) {86 if (widgets[0]) {
87 for (let w = 0, { length } = widgets; w < length; w += 1) {87 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);
89 if (!outText) {89 if (!outText) {
90 widgets.splice(w, 1);90 widgets.splice(w, 1);
91 internals.splice(w, 1)[0].unsub?.();91 UNWRAP(internals.splice(w, 1)[0]).unsub?.();
92 w -= 1;92 w -= 1;
93 length -= 1;93 length -= 1;
94 continue;94 continue;
...@@ -100,7 +100,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {...@@ -100,7 +100,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
100 ...lines.map((line) => string.trimToWidth(line, columns - 1)),100 ...lines.map((line) => string.trimToWidth(line, columns - 1)),
101 );101 );
102102
103 next = Math.min(next, internals[w].frameTime);103 next = Math.min(next, UNWRAP(internals[w]).frameTime);
104 }104 }
105105
106 newWidgetLines = newWidgetLines.slice(0, rows - 1);106 newWidgetLines = newWidgetLines.slice(0, rows - 1);
...@@ -137,31 +137,32 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {...@@ -137,31 +137,32 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
137 lines.length,137 lines.length,
138 string.countNewlines(buffer),138 string.countNewlines(buffer),
139 );139 );
140 let clearLinesBottom = Math.min(140 const oldLines = lines.slice(clearLinesTop);
141 lines.length - clearLinesTop,
142 Math.max(0, lines.length - (newWidgetLines?.length ?? 0)),
143 );
144 writeInteractive(141 writeInteractive(
145 ansi.syncStart + ansi.startOfLine +142 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 : "") +
152 // skip up the shared widget space143 // skip up the shared widget space
153 ansi.cursorUp(lines.length - clearLinesBottom) +144 ansi.cursorUp(lines.length - clearLinesTop + 1) +
154 // clear the lines to contain `buffer`145 // clear the lines to contain `buffer`
155 (ansi.clearFullLine + ansi.startOfNextLine)146 ansi.clearFullLine +
156 .repeat(clearLinesTop) +147 (ansi.cursorUp(1) + ansi.clearFullLine)
157 ansi.cursorUp(clearLinesTop),148 .repeat(clearLinesTop - 1),
158 );149 );
159 // then write output lines on standard out150 // then write output lines on standard out
160 writeOutput(buffer);151 writeOutput(buffer);
161 writeInteractive(152 writeInteractive(
162 // the widget text153 // the widget text
163 newWidgetLines.join("\n") + "\n" +154 newWidgetLines.map((newLine, i) =>
164 ansi.syncEnd,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,
165 );166 );
166 } else {167 } else {
167 const clearLinesBottom = Math.min(168 const clearLinesBottom = Math.min(
...@@ -178,11 +179,17 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {...@@ -178,11 +179,17 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
178 // skip up the widget space179 // skip up the widget space
179 ansi.cursorUp(lines.length - clearLinesBottom) +180 ansi.cursorUp(lines.length - clearLinesBottom) +
180 // the widget text181 // the widget text
181 newWidgetLines.map((x) =>182 newWidgetLines.map((newLine, i) =>
182 (x.includes("\x1b") && !x.endsWith(ansi.reset)183 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
183 ? x + ansi.reset184 ? newLine + ansi.reset
184 : x) +185 : newLine) +
185 ansi.clearToEndOfLine + "\n"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"
186 ).join("") +193 ).join("") +
187 ansi.syncEnd,194 ansi.syncEnd,
188 );195 );
...@@ -247,7 +254,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {...@@ -247,7 +254,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
247 return ts.defer(() => {254 return ts.defer(() => {
248 const i = widgets.indexOf(w);255 const i = widgets.indexOf(w);
249 widgets.splice(i, 1);256 widgets.splice(i, 1);
250 internals.splice(i, 1)[0].unsub?.();257 UNWRAP(internals.splice(i, 1)[0]).unsub?.();
251 redrawSoon(0);258 redrawSoon(0);
252 });259 });
253 },260 },
...@@ -308,8 +315,9 @@ export function logger(env: LoggerOptions): log.Scope {...@@ -308,8 +315,9 @@ export function logger(env: LoggerOptions): log.Scope {
308315
309type Level = 0 | 1 | 2;316type Level = 0 | 1 | 2;
310317
311import * as ansi from "./ansi.ts";318import * as ansi from "lib/log/ansi.ts";
312import * as string from "../string.ts";319import * as string from "lib/string.ts";
313import * as ts from "../ts.ts";320import * as ts from "lib/ts.ts";
314import * as util from "node:util";321import * as util from "node:util";
322import { UNWRAP } from "lib/assert.ts";
315import type * as log from "../log.ts";323import type * as log from "../log.ts";