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";