| author | |
| committer | |
| log | 17cea8e03404d3146f5a90c020abae96f4e142fe |
| tree | 69d8056c334e145546bc008c76c3c04367742369 |
| parent | c5236cd3ee2b76999ae6101cd189cdaac23ce0cf |
| signature | Commit is signed but in an unrecognized format. |
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 @@ |
| 1 | import { ASSERT, UNWRAP } from "lib/assert.ts"; | |
| 2 | import { test } from "node:test"; | |
| 3 | import * as headless from "lib/log/headless.ts"; | |
| 4 | ||
| 5 | class 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 | ||
| 103 | test("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 | ||
| 116 | test("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 | ||
| 157 | function sync(text: string[]) { | |
| 158 | return ansi.syncStart + text.join("") + ansi.syncEnd; | |
| 159 | } | |
| 160 | ||
| 161 | import * as ansi from "lib/log/ansi.ts"; |
lib/log/ansi.ts+30-4| ... | ... | @@ -6,7 +6,7 @@ export const fgRed: string = `\x1b[31m`; |
| 6 | 6 | export const fgGreen: string = `\x1b[32m`; |
| 7 | 7 | export const fgYellow: string = `\x1b[33m`; |
| 8 | 8 | export const fgBlue: string = `\x1b[34m`; |
| 9 | export const fgPurple: string = `\x1b[35m`; | |
| 9 | export const fgMagenta: string = `\x1b[35m`; | |
| 10 | 10 | export const fgCyan: string = `\x1b[36m`; |
| 11 | 11 | export const fgWhite: string = `\x1b[37m`; |
| 12 | 12 | export const fgBrightBlack: string = `\x1b[90m`; |
| ... | ... | @@ -14,7 +14,7 @@ export const fgBrightRed: string = `\x1b[91m`; |
| 14 | 14 | export const fgBrightGreen: string = `\x1b[92m`; |
| 15 | 15 | export const fgBrightYellow: string = `\x1b[93m`; |
| 16 | 16 | export const fgBrightBlue: string = `\x1b[94m`; |
| 17 | export const fgBrightPurple: string = `\x1b[95m`; | |
| 17 | export const fgBrightMagenta: string = `\x1b[95m`; | |
| 18 | 18 | export const fgBrightCyan: string = `\x1b[96m`; |
| 19 | 19 | export const fgBrightWhite: string = `\x1b[97m`; |
| 20 | 20 | export function fg256(n: number): string { |
| ... | ... | @@ -30,7 +30,7 @@ export const bgRed: string = `\x1b[41m`; |
| 30 | 30 | export const bgGreen: string = `\x1b[42m`; |
| 31 | 31 | export const bgYellow: string = `\x1b[43m`; |
| 32 | 32 | export const bgBlue: string = `\x1b[44m`; |
| 33 | export const bgPurple: string = `\x1b[45m`; | |
| 33 | export const bgMagenta: string = `\x1b[45m`; | |
| 34 | 34 | export const bgCyan: string = `\x1b[46m`; |
| 35 | 35 | export const bgWhite: string = `\x1b[47m`; |
| 36 | 36 | export const bgBrightBlack: string = `\x1b[100m`; |
| ... | ... | @@ -38,7 +38,7 @@ export const bgBrightRed: string = `\x1b[101m`; |
| 38 | 38 | export const bgBrightGreen: string = `\x1b[102m`; |
| 39 | 39 | export const bgBrightYellow: string = `\x1b[103m`; |
| 40 | 40 | export const bgBrightBlue: string = `\x1b[104m`; |
| 41 | export const bgBrightPurple: string = `\x1b[105m`; | |
| 41 | export const bgBrightMagenta: string = `\x1b[105m`; | |
| 42 | 42 | export const bgBrightCyan: string = `\x1b[106m`; |
| 43 | 43 | export const bgBrightWhite: string = `\x1b[107m`; |
| 44 | 44 | export function bg256(n: number): string { |
| ... | ... | @@ -92,3 +92,29 @@ export function mergeStyles(code: string): string { |
| 92 | 92 | } |
| 93 | 93 | return `\x1b[${styles.join(";")}m`; |
| 94 | 94 | } |
| 95 | ||
| 96 | export 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 | ||
| 120 | import * as self from "./ansi.ts"; |
lib/log/headless.ts+36-28| ... | ... | @@ -85,10 +85,10 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { |
| 85 | 85 | let next = Infinity; |
| 86 | 86 | if (widgets[0]) { |
| 87 | 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 | 89 | if (!outText) { |
| 90 | 90 | widgets.splice(w, 1); |
| 91 | internals.splice(w, 1)[0].unsub?.(); | |
| 91 | UNWRAP(internals.splice(w, 1)[0]).unsub?.(); | |
| 92 | 92 | w -= 1; |
| 93 | 93 | length -= 1; |
| 94 | 94 | continue; |
| ... | ... | @@ -100,7 +100,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { |
| 100 | 100 | ...lines.map((line) => string.trimToWidth(line, columns - 1)), |
| 101 | 101 | ); |
| 102 | 102 | |
| 103 | next = Math.min(next, internals[w].frameTime); | |
| 103 | next = Math.min(next, UNWRAP(internals[w]).frameTime); | |
| 104 | 104 | } |
| 105 | 105 | |
| 106 | 106 | newWidgetLines = newWidgetLines.slice(0, rows - 1); |
| ... | ... | @@ -137,31 +137,32 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { |
| 137 | 137 | lines.length, |
| 138 | 138 | string.countNewlines(buffer), |
| 139 | 139 | ); |
| 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); | |
| 144 | 141 | writeInteractive( |
| 145 | 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 | 143 | // skip up the shared widget space |
| 153 | ansi.cursorUp(lines.length - clearLinesBottom) + | |
| 144 | ansi.cursorUp(lines.length - clearLinesTop + 1) + | |
| 154 | 145 | // 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), | |
| 158 | 149 | ); |
| 159 | 150 | // then write output lines on standard out |
| 160 | 151 | writeOutput(buffer); |
| 161 | 152 | writeInteractive( |
| 162 | 153 | // 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, | |
| 165 | 166 | ); |
| 166 | 167 | } else { |
| 167 | 168 | const clearLinesBottom = Math.min( |
| ... | ... | @@ -178,11 +179,17 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { |
| 178 | 179 | // skip up the widget space |
| 179 | 180 | ansi.cursorUp(lines.length - clearLinesBottom) + |
| 180 | 181 | // 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" | |
| 186 | 193 | ).join("") + |
| 187 | 194 | ansi.syncEnd, |
| 188 | 195 | ); |
| ... | ... | @@ -247,7 +254,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { |
| 247 | 254 | return ts.defer(() => { |
| 248 | 255 | const i = widgets.indexOf(w); |
| 249 | 256 | widgets.splice(i, 1); |
| 250 | internals.splice(i, 1)[0].unsub?.(); | |
| 257 | UNWRAP(internals.splice(i, 1)[0]).unsub?.(); | |
| 251 | 258 | redrawSoon(0); |
| 252 | 259 | }); |
| 253 | 260 | }, |
| ... | ... | @@ -308,8 +315,9 @@ export function logger(env: LoggerOptions): log.Scope { |
| 308 | 315 | |
| 309 | 316 | type Level = 0 | 1 | 2; |
| 310 | 317 | |
| 311 | import * as ansi from "./ansi.ts"; | |
| 312 | import * as string from "../string.ts"; | |
| 313 | import * as ts from "../ts.ts"; | |
| 318 | import * as ansi from "lib/log/ansi.ts"; | |
| 319 | import * as string from "lib/string.ts"; | |
| 320 | import * as ts from "lib/ts.ts"; | |
| 314 | 321 | import * as util from "node:util"; |
| 322 | import { UNWRAP } from "lib/assert.ts"; | |
| 315 | 323 | import type * as log from "../log.ts"; |