From 17cea8e03404d3146f5a90c020abae96f4e142fe Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 27 Sep 2025 17:27:56 -0700 Subject: [PATCH] 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... --- lib/log.test.ts | 161 ++++++++++++++++++++++++++++++++++++++++++++ lib/log/ansi.ts | 34 ++++++++-- lib/log/headless.ts | 64 ++++++++++-------- 3 files changed, 227 insertions(+), 32 deletions(-) create mode 100644 lib/log.test.ts diff --git a/lib/log.test.ts b/lib/log.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a80ade55bc36eddcd3f3a33245c193335bd385c --- /dev/null +++ b/lib/log.test.ts @@ -0,0 +1,161 @@ +import { ASSERT, UNWRAP } from "lib/assert.ts"; +import { test } from "node:test"; +import * as headless from "lib/log/headless.ts"; + +class TestWidgetHost implements headless.WidgetHost { + columns = 80; + rows = 33; + + now = 0; + + stdout: string = ""; + stderr: string = ""; + out: string = ""; + writeCalls = 0; + + waitDuration = 0; + waiter: (() => void) | null = null; + + writeLine: headless.WidgetHost["writeLine"]; + getDrawLock: headless.WidgetHost["getDrawLock"]; + startWidget: headless.WidgetHost["startWidget"]; + cancel: headless.WidgetHost["cancel"]; + + constructor() { + const host = headless.widgetHost({ + writeInteractive: (text) => { + this.stderr += text; + this.out += text; + this.writeCalls += 1; + }, + writeOutput: (text) => { + this.stdout += text; + this.out += text; + this.writeCalls += 1; + }, + now: () => { + return this.now; + }, + wait: (ms, cb) => { + this.waitDuration = ms; + this.waiter = cb; + return () => this.waiter = null; + }, + getSize: () => { + return this; + }, + }); + this.writeLine = host.writeLine; + this.getDrawLock = host.getDrawLock; + this.startWidget = host.startWidget; + this.cancel = host.cancel; + } + + expectFrame(ms: number, { stdout, stderr, out }: { + stdout?: string; + stderr?: string; + out?: string; + }) { + const { waitDuration } = this; + ASSERT( + ms === waitDuration, + `expected ${ms}ms to pass, only ${waitDuration}`, + ); + + const callback = UNWRAP(this.waiter, "no pending frame"); + this.waiter = null; + this.now += this.waitDuration; + callback(); + + ASSERT( + out == null || this.out === out, + () => + `interweved out does not match\n` + + `expected: ${ansi.debugAnsi(out ?? "")}\n` + + `actual: ${ansi.debugAnsi(this.out)}\n`, + ); + ASSERT( + stdout == null || this.stdout === stdout, + () => + `standard out does not match\n` + + `expected: ${ansi.debugAnsi(stdout ?? "")}\n` + + `actual: ${ansi.debugAnsi(this.stdout)}\n`, + ); + ASSERT( + stderr == null || this.stderr === stderr, + () => + `interactive out does not match\n` + + `expected: ${ansi.debugAnsi(stderr ?? "")}\n` + + `actual: ${ansi.debugAnsi(this.stderr)}\n`, + ); + this.stdout = ""; + this.stderr = ""; + this.out = ""; + } + + [Symbol.dispose]() { + ASSERT(!this.waiter, "there is a pending write!"); + ASSERT(!this.stdout, "unread standard out" + this.stdout); + ASSERT(!this.stderr, "unread interactive out" + this.stderr); + } +} + +test("writeLine buffers", () => { + using host = new TestWidgetHost(); + host.writeLine("hello world"); + host.writeLine("and so on"); + host.expectFrame(0, { + stdout: "hello world\nand so on\n", + }); + host.writeLine("more logs"); + host.expectFrame(0, { + stdout: "more logs\n", + }); +}); + +test("widget with interweaving logs", () => { + const host = new TestWidgetHost(); + + const _ = host.startWidget({ + format: (now) => `line one ${now}\nline two`, + }); + + host.expectFrame(0, { + stderr: sync([ + ansi.startOfLine, + "line one 0\nline two\n", + ]), + }); + + host.writeLine("info: line of log"); + host.expectFrame(0, { + out: sync([ + ansi.startOfLine, + ansi.cursorUp(2), + ansi.clearFullLine, + "info: line of log\n", + "line one 0\nline two\n", + ]), + }); + + host.writeLine("warn: line of log"); + host.writeLine("error: line of log"); + host.expectFrame(0, { + out: sync([ + ansi.startOfLine, + ansi.cursorUp(1), + ansi.clearFullLine, + ansi.cursorUp(1), + ansi.clearFullLine, + "warn: line of log\n", + "error: line of log\n", + "line one 0\nline two\n", + ]), + }); +}); + +function sync(text: string[]) { + return ansi.syncStart + text.join("") + ansi.syncEnd; +} + +import * as ansi from "lib/log/ansi.ts"; diff --git a/lib/log/ansi.ts b/lib/log/ansi.ts index d20977b4e2a9b970a0eb73f7475bbbb852763f64..4dbce083c9f3e14303f77339c549269d9438cd8f 100644 --- a/lib/log/ansi.ts +++ b/lib/log/ansi.ts @@ -6,7 +6,7 @@ export const fgRed: string = `\x1b[31m`; export const fgGreen: string = `\x1b[32m`; export const fgYellow: string = `\x1b[33m`; export const fgBlue: string = `\x1b[34m`; -export const fgPurple: string = `\x1b[35m`; +export const fgMagenta: string = `\x1b[35m`; export const fgCyan: string = `\x1b[36m`; export const fgWhite: string = `\x1b[37m`; export const fgBrightBlack: string = `\x1b[90m`; @@ -14,7 +14,7 @@ export const fgBrightRed: string = `\x1b[91m`; export const fgBrightGreen: string = `\x1b[92m`; export const fgBrightYellow: string = `\x1b[93m`; export const fgBrightBlue: string = `\x1b[94m`; -export const fgBrightPurple: string = `\x1b[95m`; +export const fgBrightMagenta: string = `\x1b[95m`; export const fgBrightCyan: string = `\x1b[96m`; export const fgBrightWhite: string = `\x1b[97m`; export function fg256(n: number): string { @@ -30,7 +30,7 @@ export const bgRed: string = `\x1b[41m`; export const bgGreen: string = `\x1b[42m`; export const bgYellow: string = `\x1b[43m`; export const bgBlue: string = `\x1b[44m`; -export const bgPurple: string = `\x1b[45m`; +export const bgMagenta: string = `\x1b[45m`; export const bgCyan: string = `\x1b[46m`; export const bgWhite: string = `\x1b[47m`; export const bgBrightBlack: string = `\x1b[100m`; @@ -38,7 +38,7 @@ export const bgBrightRed: string = `\x1b[101m`; export const bgBrightGreen: string = `\x1b[102m`; export const bgBrightYellow: string = `\x1b[103m`; export const bgBrightBlue: string = `\x1b[104m`; -export const bgBrightPurple: string = `\x1b[105m`; +export const bgBrightMagenta: string = `\x1b[105m`; export const bgBrightCyan: string = `\x1b[106m`; export const bgBrightWhite: string = `\x1b[107m`; export function bg256(n: number): string { @@ -92,3 +92,29 @@ export function mergeStyles(code: string): string { } return `\x1b[${styles.join(";")}m`; } + +export function debugAnsi(text: string): string { + // deno-fmt-ignore + const verbaitim: (keyof typeof self)[] = [ + "bgReset", "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta", + "bgCyan", "bgWhite", "bgBrightBlack", "bgBrightRed", "bgBrightGreen", + "bgBrightYellow", "bgBrightBlue", "bgBrightMagenta", "bgBrightCyan", + "bgBrightWhite", "fgReset", "fgBlack", "fgRed", "fgGreen", "fgYellow", + "fgBlue", "fgMagenta", "fgCyan", "fgWhite", "fgBrightBlack", "fgBrightRed", + "fgBrightGreen", "fgBrightYellow", "fgBrightBlue", "fgBrightMagenta", + "fgBrightCyan", "fgBrightWhite", "clearToEndOfLine", "clearToStartOfLine", + "clearFullLine", "syncStart", "syncEnd" + ]; + for (const key of verbaitim) { + text = text.replaceAll( + String(self[key]), + style(key.includes("cursor") ? fgBlue : fgCyan, `\${${key}}`), + ); + } + text = text.replace(/\x1b\[(\d+)A/g, style(fgYellow, "$${cursorUp($1)}")); + text = text.replace(/\n/g, style(fgRed, "\\n")); + text = text.replace(/\r/g, style(fgMagenta, "\\r")); + return text; +} + +import * as self from "./ansi.ts"; diff --git a/lib/log/headless.ts b/lib/log/headless.ts index cedd5da43ecb1306512d3c4e322ac62be3683523..fad1c34d6fb351eecaa33e733bf03ad1b2c1bb5b 100644 --- a/lib/log/headless.ts +++ b/lib/log/headless.ts @@ -85,10 +85,10 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { let next = Infinity; if (widgets[0]) { for (let w = 0, { length } = widgets; w < length; w += 1) { - const outText = widgets[w].format(lastFlush, false); + const outText = UNWRAP(widgets[w]).format(lastFlush, false); if (!outText) { widgets.splice(w, 1); - internals.splice(w, 1)[0].unsub?.(); + UNWRAP(internals.splice(w, 1)[0]).unsub?.(); w -= 1; length -= 1; continue; @@ -100,7 +100,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { ...lines.map((line) => string.trimToWidth(line, columns - 1)), ); - next = Math.min(next, internals[w].frameTime); + next = Math.min(next, UNWRAP(internals[w]).frameTime); } newWidgetLines = newWidgetLines.slice(0, rows - 1); @@ -137,31 +137,32 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { lines.length, string.countNewlines(buffer), ); - let clearLinesBottom = Math.min( - lines.length - clearLinesTop, - Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), - ); + const oldLines = lines.slice(clearLinesTop); writeInteractive( ansi.syncStart + ansi.startOfLine + - // clear the bottom lines - (clearLinesBottom - ? ansi.cursorUp(1) + - (ansi.clearToEndOfLine + ansi.cursorUp(1)) - .repeat(clearLinesBottom) - : "") + // skip up the shared widget space - ansi.cursorUp(lines.length - clearLinesBottom) + + ansi.cursorUp(lines.length - clearLinesTop + 1) + // clear the lines to contain `buffer` - (ansi.clearFullLine + ansi.startOfNextLine) - .repeat(clearLinesTop) + - ansi.cursorUp(clearLinesTop), + ansi.clearFullLine + + (ansi.cursorUp(1) + ansi.clearFullLine) + .repeat(clearLinesTop - 1), ); // then write output lines on standard out writeOutput(buffer); writeInteractive( // the widget text - newWidgetLines.join("\n") + "\n" + - ansi.syncEnd, + newWidgetLines.map((newLine, i) => + (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset) + ? newLine + ansi.reset + : newLine) + + // clear rest of line if needed + (oldLines[i] && + string.widthInTerminal(oldLines[i]) > + string.widthInTerminal(newLine) + ? ansi.clearToEndOfLine + : "") + + "\n" + ).join("") + ansi.syncEnd, ); } else { const clearLinesBottom = Math.min( @@ -178,11 +179,17 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { // skip up the widget space ansi.cursorUp(lines.length - clearLinesBottom) + // the widget text - newWidgetLines.map((x) => - (x.includes("\x1b") && !x.endsWith(ansi.reset) - ? x + ansi.reset - : x) + - ansi.clearToEndOfLine + "\n" + newWidgetLines.map((newLine, i) => + (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset) + ? newLine + ansi.reset + : newLine) + + // clear rest of line if needed + (lines[i] && + string.widthInTerminal(lines[i]) > + string.widthInTerminal(newLine) + ? ansi.clearToEndOfLine + : "") + + "\n" ).join("") + ansi.syncEnd, ); @@ -247,7 +254,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { return ts.defer(() => { const i = widgets.indexOf(w); widgets.splice(i, 1); - internals.splice(i, 1)[0].unsub?.(); + UNWRAP(internals.splice(i, 1)[0]).unsub?.(); redrawSoon(0); }); }, @@ -308,8 +315,9 @@ export function logger(env: LoggerOptions): log.Scope { type Level = 0 | 1 | 2; -import * as ansi from "./ansi.ts"; -import * as string from "../string.ts"; -import * as ts from "../ts.ts"; +import * as ansi from "lib/log/ansi.ts"; +import * as string from "lib/string.ts"; +import * as ts from "lib/ts.ts"; import * as util from "node:util"; +import { UNWRAP } from "lib/assert.ts"; import type * as log from "../log.ts"; -- 2.54.0