diff --git a/lib/log.test.ts b/lib/log.test.ts index b964a83f27abeb9460e5673b7e010bec53b064e8..ef1cd82c85dd60a80c3676c3f5c2bd768e8c1f53 100644 --- a/lib/log.test.ts +++ b/lib/log.test.ts @@ -524,6 +524,394 @@ describe("log widgets", () => { host.cancel(); }); + test("empty string keeps the widget alive", () => { + const host = new testing.MockScreen(); + let text = ""; + const w = UNWRAP(host.startWidget({ format: () => text })); + // an empty frame draws nothing, but the widget is not detached + host.expectFrame(0, { merged: "" }); + text = "now!"; + w.redraw(); + host.expectFrame(0, { + stderr: testing.MockScreen.sync(["now!\n"]), + }); + text = ""; + w.redraw(); + host.expectFrame(0, { + stderr: testing.MockScreen.sync([ + ansi.cursorUp(1), + ansi.clearFullLine, + ]), + }); + w.stop(); + host.expectFrame(0, { merged: "" }); + host.cancel(); + }); + + test("leading blank line in widget output", () => { + const host = new testing.MockScreen(); + using _ = host.startWidget({ format: () => "\nfoo" }); + host.expectFrame(0, { + stderr: testing.MockScreen.sync([ + "\n", + "foo\n", + ]), + }); + host.cancel(); + }); + + test("empty widget frame with a mid-line draw lock release", () => { + // regression: this combination used to crash an assertion, because an + // empty first widget line was treated as "no widgets on screen" + const host = new testing.MockScreen(); + let text = ""; + const w = UNWRAP(host.startWidget({ format: () => ({ text }) })); + host.expectFrame(0, { merged: "" }); + const lock = host.getDrawLock("short"); + lock.release("cursor-middle-of-line"); + host.expectFrame(0, { merged: "" }); + // the foreign partial line is respected once the widget renders + text = "hi"; + w.redraw(); + host.expectFrame(0, { + stderr: ansi.cursorSave + "\n" + testing.MockScreen.sync(["hi\n"]), + }); + host.cancel(); + }); + + test("temporary unlock is used when available", () => { + const host = new testing.MockScreen({ temporaryUnlocking: true }); + using _ = host.startWidget({ format: () => "w" }); + host.expectFrame(0, { + stderr: testing.MockScreen.sync(["w\n"]), + }); + ASSERT(host.hasTerminalLock === "locked"); + const lock = host.getDrawLock("short"); + ASSERT(host.hasTerminalLock === "temporary-unlock"); + host.expectFrame(null, { + stderr: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine, + }); + lock.release("cursor-start-of-line"); + ASSERT(host.hasTerminalLock === "locked"); + host.expectFrame(0, { + stderr: "w\n" + ansi.syncEnd, + }); + host.cancel(); + }); + + test("cursor restore is not replayed for stale saves", () => { + const host = new testing.MockScreen(); + using _ = host.startWidget({ format: () => "w" }); + host.expectFrame(0, { + stderr: testing.MockScreen.sync(["w\n"]), + }); + + // a foreign partial line forces a cursor save + { + const lock = host.getDrawLock("short"); + host.expectFrame(null, { + merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine, + }); + lock.release("cursor-middle-of-line"); + host.expectFrame(0, { + merged: ansi.cursorSave + "\n" + "w\n" + ansi.syncEnd, + }); + } + + // clearing the widget restores the saved cursor, exactly once + { + const lock = host.getDrawLock("short"); + host.expectFrame(null, { + merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine + + ansi.cursorRestore, + }); + lock.release("cursor-start-of-line"); + host.expectFrame(0, { + merged: "w\n" + ansi.syncEnd, + }); + } + + // regression: the stale save register must not be restored again + { + const lock = host.getDrawLock("short"); + host.expectFrame(null, { + merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine, + }); + lock.release("cursor-start-of-line"); + host.expectFrame(0, { + merged: "w\n" + ansi.syncEnd, + }); + } + host.cancel(); + }); + + test("log lines wider than the terminal", () => { + const host = new testing.MockScreen(); + host.columns = 10; + using _ = host.startWidget({ format: () => "w1\nw2" }); + host.expectFrame(0, { + stderr: testing.MockScreen.sync(["w1\nw2\n"]), + }); + // a 15 cell line occupies two rows, so both widget rows get cleared + host.writeOutput("123456789012345\n"); + host.expectFrame(0, { + merged: testing.MockScreen.sync([ + ansi.cursorUp(1), + ansi.clearFullLine, + ansi.cursorUp(1), + ansi.clearFullLine, + "123456789012345\n", + "w1\n", + "w2\n", + ]), + }); + host.cancel(); + }); + + test("partial line wider than the terminal", () => { + const host = new testing.MockScreen(); + host.columns = 10; + using _ = host.startWidget({ format: () => "w1\nw2" }); + host.expectFrame(0, { + stderr: testing.MockScreen.sync(["w1\nw2\n"]), + }); + // 14 cells: one wrapped row plus a partial row at column 4 + host.writeOutput("12345678901234"); + host.expectFrame(0, { + merged: testing.MockScreen.sync([ + ansi.cursorUp(1), + ansi.clearFullLine, + ansi.cursorUp(1), + ansi.clearFullLine, + "12345678901234", + "\n", + "w1\n", + "w2\n", + ]), + }); + // the continuation point is column 4 of the partial row, not column 14 + host.writeOutput(" more\n"); + host.expectFrame(0, { + merged: testing.MockScreen.sync([ + ansi.cursorUp(3) + ansi.cursorRight(4), + " more\n", + "w1\n", + "w2\n", + ]), + }); + host.cancel(); + }); + + test("partial line with ansi codes measures visible width", () => { + const host = new testing.MockScreen(); + using _ = host.startWidget({ format: () => "w" }); + host.expectFrame(0, { + stderr: testing.MockScreen.sync(["w\n"]), + }); + host.writeOutput(ansi.fgBlue + "blue" + ansi.fgReset); + host.expectFrame(0, { + merged: testing.MockScreen.sync([ + ansi.cursorUp(1), + ansi.clearFullLine, + ansi.fgBlue + "blue" + ansi.fgReset, + "\n", + "w\n", + ]), + }); + // "blue" is 4 cells wide; the escape sequences take none + host.writeOutput(" sky\n"); + host.expectFrame(0, { + merged: testing.MockScreen.sync([ + ansi.cursorUp(2) + ansi.cursorRight(4), + " sky\n", + "w\n", + ]), + }); + host.cancel(); + }); + + test("off-screen output does not move widgets", () => { + // models stdout redirected to a file while stderr stays the terminal: + // log output flushes to stdout, but the widget block is untouched by it + const host = new testing.MockScreen({ outputSharesScreen: false }); + using _ = host.startWidget({ format: () => "w1\nw2" }); + host.expectFrame(0, { + stderr: testing.MockScreen.sync(["w1\nw2\n"]), + }); + host.writeOutput("a long log line that would have wrapped on screen\n"); + host.expectFrame(0, { + stdout: "a long log line that would have wrapped on screen\n", + stderr: testing.MockScreen.sync([ + ansi.cursorUp(2), + "w1\nw2\n", + ]), + }); + // partial lines in the redirected stream do not shift the widgets either + host.writeOutput("partial"); + host.expectFrame(0, { + stdout: "partial", + stderr: testing.MockScreen.sync([ + ansi.cursorUp(2), + "w1\nw2\n", + ]), + }); + host.cancel(); + }); + + test("widgets start below a partial line", () => { + const host = new testing.MockScreen(); + let text = ""; + const w = UNWRAP(host.startWidget({ format: () => text })); + host.expectFrame(0, { merged: "" }); + host.writeOutput("part"); + host.expectFrame(0, { stdout: "part", merged: "part" }); + text = "W"; + w.redraw(); + host.expectFrame(0, { + stderr: testing.MockScreen.sync(["\n", "W\n"]), + }); + // the partial line can still be continued afterwards + host.writeOutput(" two\n"); + host.expectFrame(0, { + merged: testing.MockScreen.sync([ + ansi.cursorUp(2) + ansi.cursorRight(4), + " two\n", + "W\n", + ]), + }); + host.cancel(); + }); + + test("resize invalidates the widget area", () => { + const host = new testing.MockScreen(); + using _ = host.startWidget({ format: () => "w1\nw2" }); + host.expectFrame(0, { + stderr: testing.MockScreen.sync(["w1\nw2\n"]), + }); + host.resize(40); + host.expectFrame(0, { + merged: ansi.cursorUp(2) + "\r" + ansi.clearToEndOfScreen + + testing.MockScreen.sync(["w1\nw2\n"]), + }); + host.cancel(); + }); + + test("widget redraw during another widget's render", () => { + const host = new testing.MockScreen(); + let kicked = false; + const b = UNWRAP(host.startWidget({ format: () => "b" })); + using _ = host.startWidget({ + format: () => { + if (!kicked) { + kicked = true; + b.redraw(); + } + return "a"; + }, + }); + host.expectFrame(0, { + stderr: testing.MockScreen.sync(["b\na\n"]), + }); + // the request from inside the render pass produces a follow-up frame + host.expectFrame(0, { + stderr: testing.MockScreen.sync([ + ansi.cursorUp(2), + "b\na\n", + ]), + }); + host.expectNone(); + host.cancel(); + }); + + test("widget stopped during another widget's render", () => { + const host = new testing.MockScreen(); + let b: log.WidgetInstance | null = null; + using _ = host.startWidget({ + format: () => { + b?.stop(); + b = null; + return "a"; + }, + }); + b = host.startWidget({ format: () => "b" }); + host.expectFrame(0, { + stderr: testing.MockScreen.sync(["a\n"]), + }); + // the stop request also schedules a follow-up frame; it draws no change + host.expectFrame(0, { + stderr: testing.MockScreen.sync([ + ansi.cursorUp(1), + "a\n", + ]), + }); + host.expectNone(); + host.cancel(); + }); + + test("fps setter schedules frames", () => { + const host = new testing.MockScreen(); + const w = UNWRAP(host.startWidget({ format: ({ now }) => `t=${now}` })); + host.expectFrame(0, { + stderr: testing.MockScreen.sync(["t=0\n"]), + }); + host.expectNone(); + w.fps = 1; + host.expectFrame(0, { + stderr: testing.MockScreen.sync([ + ansi.cursorUp(1), + "t=0\n", + ]), + }); + host.expectWithoutConsume(1000); + w.fps = null; + host.expectFrame(0, { + stderr: testing.MockScreen.sync([ + ansi.cursorUp(1), + "t=0\n", + ]), + }); + host.expectNone(); + w.stop(); + host.expectFrame(0, { + stderr: testing.MockScreen.sync([ + ansi.cursorUp(1), + ansi.clearFullLine, + ]), + }); + host.cancel(); + }); + + test("node host patches and restores std streams", async () => { + const proc = UNWRAP(node.process); + const origOut = proc.stdout.write; + const origErr = proc.stderr.write; + let stdoutData = ""; + let stderrData = ""; + proc.stdout.write = (chunk) => (stdoutData += chunk, true); + proc.stderr.write = (chunk) => (stderrData += chunk, true); + const myOut = proc.stdout.write; + const myErr = proc.stderr.write; + try { + const host = log.defaultNodeProcessWidgetHost(proc, true); + for (let i = 0; i < 3; i += 1) { + host.writeOutput(`line ${i}\n`); + await new Promise((resolve) => setTimeout(resolve, 2)); + } + host.cancel(); + // regression: the unpatch used to clobber stdout with stderr's methods + // and leave a permanently growing wrapper chain on stderr + ASSERT(proc.stdout.write === myOut, "stdout.write was not restored"); + ASSERT(proc.stderr.write === myErr, "stderr.write was not restored"); + // log output lands on real stdout, so it stays redirectable + ASSERT(stdoutData.includes("line 0\n")); + ASSERT(stdoutData.includes("line 2\n")); + ASSERT(stderrData === ""); + } finally { + proc.stdout.write = origOut; + proc.stderr.write = origErr; + } + }); + test("writing during a render function is OK", () => { const host = new testing.MockScreen(); @@ -564,6 +952,8 @@ describe("log widgets", () => { }); import { describe, test } from "vitest"; +import { ASSERT, UNWRAP } from "./assert.ts"; import * as log from "./log.ts"; +import * as node from "./node.ts"; import * as ansi from "./string/ansi.ts"; import * as testing from "./testing.ts"; diff --git a/lib/log.ts b/lib/log.ts index 4ac752b2ea37f7d2a4c2d86f06ea7c8fad033e68..a2726a454e33dfeeb98acb74342f4e70e60f2b8d 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -374,6 +374,15 @@ export interface TerminalWidgetHostOptions { delay: typeof async.delay; /** is there color support? */ color: boolean; + /** + * whether text passed to `writeOutput` lands on the same screen as the + * interactive output. pass `false` when output is redirected to a file or + * pipe while the interactive stream remains a terminal; the widget cursor + * math then ignores log output entirely, since written rows do not + * displace the widget block. + * @default true + */ + outputSharesScreen?: boolean; } /** @@ -385,9 +394,17 @@ export interface TerminalLock { writeInteractive(text: string): void; /** recieves log content from `write` (pre-buffered; should flush immediately) */ writeOutput(text: string): void; - /** called often. TODO: convert this into a subscription */ - getSize(): { columns: number; rows: number }; - // size(cb: (value: { width: number, height: number }) => void): () => void; + /** + * subscribe to the terminal size. the callback must fire synchronously + * with the current size before this function returns, and again whenever + * the size changes (SIGWINCH). returns an unsubscribe function. on a size + * change, the widget host erases its stale drawing and repaints, since a + * resize rewraps previously drawn rows and invalidates all relative + * cursor math. + */ + observeSize( + callback: (size: { columns: number; rows: number }) => void, + ): () => void; /** temporarily free the lock */ temporaryUnlock?(): () => void; /** completely free the lock */ @@ -424,7 +441,6 @@ export interface WidgetHost { /** @internal state */ interface WidgetState { frameTime: number; - next: number; } /** @@ -435,15 +451,20 @@ export function createTerminalWidgetHost( env: TerminalWidgetHostOptions, ): WidgetHost { const { lockTerminal, now, delay, writeOutputTemporaryLock, color } = env; + const outputOnScreen = env.outputSharesScreen ?? true; let timer: async.Cancelable | null = null; let rendering = false; + let pendingRedraw = false; let locks = 0; let redrawTime = 0; let lastFlush = 0; let buffer = ""; - let partialLineIndex = 0; + // visible width of the trailing partial log line (text since the last "\n" + // written to output). the cursor column is derived as `partialWidth % + // columns` at draw time, so the value survives resizes and wrapped lines. + let partialWidth = 0; let needsToSaveCursor = false; let needsToRestoreCursor = false; const widgets: WidgetOptions[] = []; @@ -451,8 +472,53 @@ export function createTerminalWidgetHost( let lines: string[] = []; let hasSyncStart = false; let terminal: TerminalLock | null = null; + let size: { columns: number; rows: number } | null = null; + let unwatchSize: (() => void) | null = null; let tempUnlock: (() => void) | null = null; + // the size subscription lives exactly as long as the terminal lock, so + // acquisition and closing are funneled through these two functions. + function acquireTerminal(): TerminalLock { + if (!terminal) { + let initial = true; + size = null; + terminal = lockTerminal(); + unwatchSize = terminal.observeSize((next) => { + size = next; + if (!initial) handleResize(); + }); + ASSERT( + size, + "TerminalLock.observeSize must call back synchronously with the current size", + ); + initial = false; + } + return terminal; + } + function closeTerminal() { + unwatchSize?.(); + unwatchSize = null; + terminal?.close(); + terminal = null; + } + + function handleResize() { + // previously drawn rows have rewrapped to the new width, so the stored + // line count no longer matches the screen. erasing from the widget top + // downward is best-effort: on shrink, rows that wrapped above the cursor + // cannot be recovered. + if (lines.length > 0 && terminal) { + terminal.writeInteractive( + ansi.cursorUp(lines.length) + "\r" + ansi.clearToEndOfScreen, + ); + lines = []; + } + // the save register holds pre-resize coordinates; restoring it would + // jump somewhere unrelated. + needsToRestoreCursor = false; + requestRedraw(); + } + function redrawCallback() { timer = null; ASSERT(!rendering); @@ -464,6 +530,16 @@ export function createTerminalWidgetHost( redrawCallbackInner(); } finally { rendering = false; + if (pendingRedraw) { + pendingRedraw = false; + redrawSoon(0); + } + // cancel() during this render pass (an exit handler unwinding through + // a crashed format callback) skips its teardown; finish it here. + if (widgets.length === 0 && terminal && !timer && !buffer) { + lines = []; + closeTerminal(); + } } } @@ -477,33 +553,27 @@ export function createTerminalWidgetHost( if (!buffer) return; needsToRestoreCursor = false; needsToSaveCursor = false; - if (writeOutputTemporaryLock) { + if (!terminal && writeOutputTemporaryLock) { writeOutputTemporaryLock(buffer); - if (hasSyncStart) { - terminal ??= lockTerminal(); - terminal.writeInteractive(ansi.syncEnd); - } } else { - terminal ??= lockTerminal(); - buffer && terminal.writeOutput(buffer); - if (hasSyncStart) { - terminal ??= lockTerminal(); - terminal.writeInteractive(ansi.syncEnd); - } - terminal.close(); - terminal = null; + acquireTerminal().writeOutput(buffer); } - partialLineIndex = partialLineLength(buffer); + if (hasSyncStart) { + acquireTerminal().writeInteractive(ansi.syncEnd); + hasSyncStart = false; + } + closeTerminal(); + trackPartialWidth(buffer); buffer = ""; return; } - terminal ??= lockTerminal(); - - const { columns, rows } = terminal.getSize(); + const term = acquireTerminal(); + const { columns, rows } = UNWRAP(size); let newWidgetLines: string[] = []; let next = Infinity; - for (let w = 0, { length } = widgets; w < length; w += 1) { + // `widgets.length` is read live: a format callback may stop another widget + for (let w = 0; w < widgets.length; w += 1) { const widget = UNWRAP(widgets[w]); let out: string | { text: string } | null; try { @@ -515,50 +585,68 @@ export function createTerminalWidgetHost( } catch (e) { out = e instanceof Error ? stack.format(e, color) : errors.message(e); } - if (!out) { + if (out == null) { widgets.splice(w, 1); UNWRAP(internals.splice(w, 1)[0]); w -= 1; - length -= 1; continue; } + next = Math.min(next, UNWRAP(internals[w]).frameTime); const outText = typeof out === "string" ? out : out.text; + // an empty string is a live widget that currently displays nothing; + // only `null` detaches. + if (outText === "") continue; const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); - if (rowsLeft === 1) break; - const lines = outText.split("\n").slice(0, rowsLeft); + if (rowsLeft === 1) continue; newWidgetLines.push( - ...lines.map((line) => ansi.trimForTerminal(line, columns - 1)), + ...outText.split("\n").slice(0, rowsLeft) + .map((line) => ansi.trimForTerminal(line, columns - 1)), ); - - next = Math.min(next, UNWRAP(internals[w]).frameTime); } newWidgetLines = newWidgetLines.slice(0, rows - 1); if (next < Infinity) redrawSoon(next); - terminal ??= lockTerminal(); + const pCol = partialWidth % columns; - if (!newWidgetLines[0]) { - ASSERT(!needsToSaveCursor); + if (newWidgetLines.length === 0) { if (lines.length > 0) { - terminal.writeInteractive( + term.writeInteractive( (hasSyncStart ? "" : ansi.syncStart) // clear the widget space + (ansi.cursorUp(1) + ansi.clearFullLine) .repeat(lines.length) - + (partialLineIndex - ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex) + + (pCol + ? ansi.cursorUp(1) + ansi.cursorRight(pCol) : "") + (needsToRestoreCursor ? ansi.cursorRestore : ""), ); hasSyncStart = true; + needsToRestoreCursor = false; lines = []; } - if (buffer) terminal.writeOutput(buffer); - buffer = ""; - if (hasSyncStart) terminal.writeInteractive(ansi.syncEnd); + if (buffer) { + term.writeOutput(buffer); + trackPartialWidth(buffer); + buffer = ""; + } + if (hasSyncStart) { + term.writeInteractive(ansi.syncEnd); + hasSyncStart = false; + } + // the last widget may have detached this frame; release the terminal + // (and its patches) instead of holding the lock until the next flush + if (widgets.length === 0) closeTerminal(); return; } + if (buffer && !outputOnScreen) { + // off-screen output (e.g. stdout redirected to a file) does not + // interact with the widget block; flush it plainly and fall through + // to a pure widget redraw + term.writeOutput(buffer); + buffer = ""; + } + if (buffer) { // when writing a buffer alongside widgets, the screen may look like this // > [existing log] @@ -568,21 +656,24 @@ export function createTerminalWidgetHost( // > [widget line 3] // > [cursor is start of this line] // - // first, clear out the space where new lines are going to intersect - const createsPartialLine = partialLineLength(buffer); - // if more lines are buffered than there are widgets, only some are needed + // first, clear out the space where new lines are going to intersect. + // `span.rows` measures the cursor descent in physical rows, so log + // lines wider than the terminal are accounted for correctly. + const span = measureTerminalSpan(buffer, pCol, columns); + // if more rows are buffered than there are widgets, only some are + // needed. when a partial line exists, the buffer starts on its row + // (one above the widget block), hence the -1. const clearLinesTop = Math.min( lines.length, - string.countNewlines(buffer) - + (createsPartialLine ? 1 : 0) - + (partialLineIndex ? -1 : 0), // "consumes partial line" + span.rows + + (span.endWidth % columns > 0 ? 1 : 0) + + (pCol ? -1 : 0), ); const oldLines = lines.slice(clearLinesTop); - terminal.writeInteractive( + term.writeInteractive( (needsToSaveCursor ? ansi.cursorSave + "\n" : "") + (hasSyncStart ? "" : ansi.syncStart) - + (partialLineIndex && !lines[0] ? "\n" : "") - + ((clearLinesTop > 0 || (partialLineIndex && lines[0])) + + ((clearLinesTop > 0 || (pCol && lines.length > 0)) // clear the lines for buffer ? (clearLinesTop > 0 ? ansi.cursorUp(lines.length - clearLinesTop + 1) @@ -590,22 +681,23 @@ export function createTerminalWidgetHost( + (ansi.cursorUp(1) + ansi.clearFullLine) .repeat(clearLinesTop - 1) : "") - + (partialLineIndex && lines[0] + + (pCol && lines.length > 0 ? ansi.cursorUp( clearLinesTop > 0 ? 1 : lines.length + 1, ) - + ansi.cursorRight(partialLineIndex) + + ansi.cursorRight(pCol) : "") : ""), ); - partialLineIndex = createsPartialLine; // then write output lines on standard out - terminal.writeOutput(buffer); - terminal.writeInteractive( - // if a partial line is created, then the widgets - // have to go on the next line, to avoid breaking stdout, - // the newline gets emitted on the interactive out. - (createsPartialLine ? "\n" : "") + term.writeOutput(buffer); + term.writeInteractive( + // if the buffer leaves a partial line, the widgets have to go on the + // next line. to avoid breaking stdout, the newline gets emitted on + // the interactive out. (a line ending exactly on the terminal edge + // leaves the cursor wrap-deferred; this newline lands on the next + // row, which is also where `span.rows` placed the continuation.) + (span.endWidth > 0 ? "\n" : "") // the widget text + newWidgetLines.map((newLine, i) => (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset) @@ -620,14 +712,20 @@ export function createTerminalWidgetHost( + "\n" ).join("") + ansi.syncEnd, ); + partialWidth = span.endWidth; } else { const clearLinesBottom = Math.min( lines.length, - Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), + Math.max(0, lines.length - newWidgetLines.length), ); - terminal.writeInteractive( + term.writeInteractive( (needsToSaveCursor ? ansi.cursorSave + "\n" : "") + (hasSyncStart ? "" : ansi.syncStart) + // the first draw can land just after a partial log line; widgets + // must move below it. (`partialWidth` is zero whenever + // `needsToSaveCursor` is set, since releasing a draw lock resets + // it, so this never combines with the cursorSave newline.) + + (lines.length === 0 && partialWidth > 0 ? "\n" : "") // clear the bottom lines + (clearLinesBottom ? (ansi.cursorUp(1) + ansi.clearToEndOfLine) @@ -658,6 +756,17 @@ export function createTerminalWidgetHost( buffer = ""; } + /** + * update `partialWidth` after writing `text` to the output. rows written + * off-screen never displace the widget block, so they are not tracked. + */ + function trackPartialWidth(text: string) { + if (!outputOnScreen) return; + const i = text.lastIndexOf("\n"); + partialWidth = ansi.widthInTerminal(text.slice(i + 1)) + + (i === -1 ? partialWidth : 0); + } + function redrawSoon(ms: number) { if (locks > 0 || (ms === 0 && rendering)) return; const newRedrawTime = now() + ms; @@ -671,35 +780,40 @@ export function createTerminalWidgetHost( timer.then(redrawCallback); } + function requestRedraw() { + if (rendering) pendingRedraw = true; + else redrawSoon(0); + } + function flushAndClear(shortTermDrawLock: boolean) { timer?.cancel(); timer = null; if (lines.length > 0) { - UNWRAP(terminal).writeInteractive( + const term = UNWRAP(terminal); + const pCol = partialWidth % UNWRAP(size).columns; + term.writeInteractive( ansi.syncStart // clear the widget space + (ansi.cursorUp(1) + ansi.clearFullLine) .repeat(lines.length) - + (partialLineIndex - ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex) + + (pCol + ? ansi.cursorUp(1) + ansi.cursorRight(pCol) : "") + (shortTermDrawLock ? "" : ansi.syncEnd) + (needsToRestoreCursor ? ansi.cursorRestore : ""), ); + needsToRestoreCursor = false; lines = []; hasSyncStart = shortTermDrawLock; } if (buffer.length > 0) { - if (widgets.length === 0 && writeOutputTemporaryLock) { + if (widgets.length === 0 && !terminal && writeOutputTemporaryLock) { writeOutputTemporaryLock(buffer); } else { - terminal ??= lockTerminal(); - terminal.writeOutput(buffer); - if (widgets.length === 0) { - terminal.close(); - terminal = null; - } + acquireTerminal().writeOutput(buffer); + if (widgets.length === 0) closeTerminal(); } + trackPartialWidth(buffer); buffer = ""; } } @@ -720,8 +834,7 @@ export function createTerminalWidgetHost( if (terminal.temporaryUnlock) { tempUnlock = terminal.temporaryUnlock(); } else { - terminal.close(); - terminal = null; + closeTerminal(); } } } @@ -730,14 +843,19 @@ export function createTerminalWidgetHost( return { release(mode) { if (disposed) return; + disposed = true; locks -= 1; needsToSaveCursor ||= mode === "cursor-middle-of-line"; + // whatever the lock holder wrote has detached the cursor from any + // previously tracked partial log line. "cursor-start-of-line" + // states the cursor is on a fresh line; "cursor-middle-of-line" + // engages the save/restore dance instead. + partialWidth = 0; if (locks === 0) { tempUnlock?.(); tempUnlock = null; if (buffer.length > 0 || widgets.length > 0) redrawSoon(0); } - disposed = true; }, [Symbol.dispose]() { this.release("cursor-middle-of-line"); @@ -748,12 +866,11 @@ export function createTerminalWidgetHost( ASSERT(!widgets.includes(options), "Cannot start the same widget twice."); let fps = options.fps ?? null; const state: WidgetState = { - next: 0, frameTime: 1000 / (fps ?? 0), }; widgets.push(options); internals.push(state); - redrawSoon(0); + requestRedraw(); return { options, get fps() { @@ -762,17 +879,16 @@ export function createTerminalWidgetHost( set fps(value) { fps = value; state.frameTime = 1000 / (fps ?? 0); + // a pending frame may sit beyond the new cadence; draw to reanchor + requestRedraw(); }, - redraw() { - state.next = 0; - redrawSoon(0); - }, + redraw: requestRedraw, stop() { const i = widgets.indexOf(options); if (i === -1) return; widgets.splice(i, 1); UNWRAP(internals.splice(i, 1)[0]); - redrawSoon(0); + requestRedraw(); }, [Symbol.dispose]() { this.stop(); @@ -787,6 +903,9 @@ export function createTerminalWidgetHost( if (!rendering) flushAndClear(false); widgets.splice(0, widgets.length); internals.splice(0, internals.length); + timer?.cancel(); + timer = null; + if (!rendering) closeTerminal(); }, delay, now, @@ -794,13 +913,35 @@ export function createTerminalWidgetHost( }; } -function partialLineLength(text: string) { - const idx = text.lastIndexOf("\n"); - if (idx === -1) return text.length; - return text.length - idx - 1; +/** + * compute the cursor descent (`rows`) and trailing line width (`endWidth`) + * from writing `text` to a terminal `columns` wide, with the cursor starting + * `startWidth` cells into a line. wrapping follows the DECAWM deferred-wrap + * convention shared by modern terminals: a line of exactly `columns` cells + * leaves the cursor pending on the same row, so a newline after it descends + * only one row. a trailing line ending exactly on the boundary reports its + * continuation point at column 0 of the next row (`endWidth % columns === 0` + * with the descended row included in `rows`). + */ +function measureTerminalSpan( + text: string, + startWidth: number, + columns: number, +): { rows: number; endWidth: number } { + let rows = 0; + let width = startWidth; + const parts = text.split("\n"); + for (let i = 0; i < parts.length - 1; i += 1) { + width += ansi.widthInTerminal(UNWRAP(parts[i])); + rows += Math.max(1, Math.ceil(width / columns)); + width = 0; + } + width += ansi.widthInTerminal(UNWRAP(parts[parts.length - 1])); + rows += Math.floor(width / columns); + return { rows, endWidth: width }; } -const logColors = node.process?.stderr.isTTY ?? false; +const logColors = node.process?.stdout.isTTY ?? false; let formatLine = /* @__PURE__ */ (() => { const fwo = node.builtin("util")?.formatWithOptions; @@ -944,7 +1085,7 @@ interface GlobalCommunication { } const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log"); -const version = 4; +const version = 5; let global: GlobalCommunication = /* @__PURE__ */ (() => { const global = (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= { readme: @@ -1032,13 +1173,16 @@ export function defaultNodeProcessWidgetHost( const stderrEnd = stderr.end; function patchWriteMethod( fn: (this: T, ...args: A) => R, + touchesScreen: boolean, ) { return function(this: T, ...args: A) { using lock = disposed ? null : host.getDrawLock("short"); const ret = fn.apply(this, args); if (lock) { + // writes to a redirected stream never move the screen cursor lock.release( - (typeof args[0] === "string" + !touchesScreen + || (typeof args[0] === "string" ? args[0].endsWith("\n") : bufferEndsInNewline(args[0])) ? "cursor-start-of-line" @@ -1048,8 +1192,14 @@ export function defaultNodeProcessWidgetHost( return ret; }; } - const newStdoutWrite = stdout.write = patchWriteMethod(stdoutWrite); - const newStderrWrite = stderr.write = patchWriteMethod(stderrWrite); + const newStdoutWrite = stdout.write = patchWriteMethod( + stdoutWrite, + stdout.isTTY, + ); + const newStderrWrite = stderr.write = patchWriteMethod( + stderrWrite, + true, + ); function patchEndMethod( fn: (this: T, ...args: A) => R, ) { @@ -1090,19 +1240,34 @@ export function defaultNodeProcessWidgetHost( } return { - writeOutput: (string) => stdoutWrite.call(stderr, string), + // log output goes to real stdout so it stays redirectable; the + // `outputSharesScreen` option tells the host whether those rows + // land on the widget screen and must be counted by the cursor math + writeOutput: (string) => stdoutWrite.call(stdout, string), writeInteractive: (string) => stderrWrite.call(stderr, string), - getSize: () => process.stderr, - temporarilyUnlock() { - // no action needed + observeSize(callback) { + const emit = () => + callback({ + columns: stderr.columns ?? 80, + rows: stderr.rows ?? 24, + }); + emit(); + stderr.addListener("resize", emit); + return () => void stderr.removeListener("resize", emit); + }, + temporaryUnlock() { + // no action needed: the patched write methods re-enter the draw + // lock, which is re-entrant, so external writes flow correctly + // while the lock is held. + return () => {}; }, close() { disposed = true; // leave patches in place if something else tampered with it. if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite; - if (stderr.write === newStderrWrite) stdout.write = stderrWrite; + if (stderr.write === newStderrWrite) stderr.write = stderrWrite; if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd; - if (stderr.end === newStderrEnd) stdout.end = stderrEnd; + if (stderr.end === newStderrEnd) stderr.end = stderrEnd; for (const [key, old, patched] of restoreConsole) { if (console[key] === patched) console[key] = old; } @@ -1112,6 +1277,7 @@ export function defaultNodeProcessWidgetHost( now: () => performance.now(), delay: async.delay, color: process.stderr.isTTY, + outputSharesScreen: process.stdout.isTTY, }); process.addListener("beforeExit", () => host.cancel()); process.addListener("exit", () => host.cancel()); @@ -1156,7 +1322,16 @@ export function simpleNodeProcessWidgetHost( lockTerminal: () => ({ writeOutput: (string) => process.stdout.write(string), writeInteractive: (string) => process.stderr.write(string), - getSize: () => process.stderr, + observeSize(callback) { + const emit = () => + callback({ + columns: process.stderr.columns ?? 80, + rows: process.stderr.rows ?? 24, + }); + emit(); + process.stderr.addListener("resize", emit); + return () => void process.stderr.removeListener("resize", emit); + }, close() { // no action needed }, @@ -1164,6 +1339,7 @@ export function simpleNodeProcessWidgetHost( now: () => performance.now(), delay: async.delay, color: process.stderr.isTTY, + outputSharesScreen: process.stdout.isTTY, }) : { writeOutput: (string) => process.stdout.write(string), @@ -1201,6 +1377,8 @@ const globalLog = /* @__PURE__ */ (() => } else if (node.process) { globalWidgetHost()[ (m.level ?? "info") === "info" ? "writeOutput" : "writeError" + // colors keyed off stdout, the destination of host log output; + // `logColors` (used for inspect formatting) matches. ](formatAnsiMessage(m, node.process.stdout.isTTY)); } else { let { level = "info", [originalLogArgs]: args = [m.text], scope } = m; @@ -1233,6 +1411,5 @@ import * as async from "./async.ts"; import * as errors from "./error.ts"; import * as stack from "./log/stack.ts"; import * as node from "./node.ts"; -import * as string from "./string.ts"; import * as ansi from "./string/ansi.ts"; import * as ts from "./ts.ts"; diff --git a/lib/node.ts b/lib/node.ts index da53818545e5e85974998f94139780c6adb9d34a..90f392b2302ca134c632d43352dc3c1ef3be573e 100644 --- a/lib/node.ts +++ b/lib/node.ts @@ -30,9 +30,12 @@ interface Process { interface Tty { isTTY: boolean; - columns: number; - rows: number; + /** only present when the stream is a tty */ + columns: number | undefined; + /** only present when the stream is a tty */ + rows: number | undefined; addListener(event: string, callback: () => void): this; + removeListener(event: string, callback: () => void): this; end(text?: string | Uint8Array): this; write(text: string | Uint8Array): boolean; } diff --git a/lib/progress.test.ts b/lib/progress.test.ts index 472fb921b82753b24924a0f007f3df95ec7b4102..280a11dcfe9096780e332c49b582863c36e4fdd8 100644 --- a/lib/progress.test.ts +++ b/lib/progress.test.ts @@ -1,5 +1,5 @@ // a trivial example of how to use progress -test.skip("trivial end-to-end example", () => { +test("trivial end-to-end example", () => { const { fgBlue: FB, fgReset: FR, reset: R } = ansi; const screen = new testing.MockScreen(); // create a mock terminal const root = new progress.Root(screen); // sync with mock timers @@ -13,18 +13,20 @@ test.skip("trivial end-to-end example", () => { assert.equal(screen.timers.entries.length, 1); screen.expectFrame(1, { merged: "" }); // debounce - // TODO: why the full resets? + // widget lines containing escapes get a trailing ansi.reset from the + // renderer, so a malformed format function cannot bleed styles screen.expectFrame(0, { merged: testing.MockScreen.sync([ - `\r${FB}⠋${FR} hello${R}\n`, + `${FB}⠋${FR} hello${R}\n`, `${FB}⠋${FR} cats${R}\n`, "└─ meow\n", ]), }); + // spinners animate from the clock, so frames tick at the spinner rate screen.expectFrame(80, { merged: testing.MockScreen.sync([ - `\r` + ansi.cursorUp(3), + ansi.cursorUp(3), `${FB}⠙${FR} hello${R}\n`, `${FB}⠙${FR} cats${R}\n`, "└─ meow\n", @@ -34,13 +36,66 @@ test.skip("trivial end-to-end example", () => { a.end(); b.end(); - screen.expectFrame(80, { + // ending fires the change debounce, which stops the widget and clears it + screen.expectFrame(1, { merged: "" }); + screen.expectFrame(0, { merged: testing.MockScreen.sync([ - `\r` + ansi.cursorUp(1) + ansi.clearFullLine, ansi.cursorUp(1) + ansi.clearFullLine, ansi.cursorUp(1) + ansi.clearFullLine, + ansi.cursorUp(1) + ansi.clearFullLine, + ]), + }); + screen.expectNone(); +}); + +test("bars without estimates do not animate", () => { + const screen = new testing.MockScreen(); + const root = new progress.Root(screen); + progress.attachToScreen(root, screen); + + const a = root.start("download", { total: 4, value: 1 }); + screen.expectFrame(1, { merged: "" }); // debounce + screen.expectFrame(0, {}); // initial draw + // a bar is static between value changes, so no frame timer is pending + screen.expectNone(); + + a.inc(); + screen.expectFrame(1, { merged: "" }); + screen.expectFrame(0, {}); + screen.expectNone(); + + a.end(); + screen.expectFrame(1, { merged: "" }); + screen.expectFrame(0, {}); + screen.expectNone(); +}); + +test("attachToScreen dispose detaches from the root", () => { + const screen = new testing.MockScreen(); + const root = new progress.Root(screen); + const detach = progress.attachToScreen(root, screen); + + const a = root.start("hello"); + screen.expectFrame(1, { merged: "" }); + screen.expectFrame(0, { + stderr: testing.MockScreen.sync([ + `${ansi.style(ansi.fgBlue, "⠋")} hello${ansi.reset}\n`, + ]), + }); + + detach[Symbol.dispose](); + // the widget is stopped and removed from the screen on detach + screen.expectFrame(0, { + stderr: testing.MockScreen.sync([ + ansi.cursorUp(1), + ansi.clearFullLine, ]), }); + + // further progress activity no longer renders + a.inc(); + screen.expectFrame(1, { merged: "" }); // root's own debounce still runs + screen.expectNone(); }); describe("encodeEventStream", (t) => { @@ -73,6 +128,99 @@ describe("encodeEventStream", (t) => { [1], ]); }); + + test("end closes the stream and decode resolves", async () => { + const root = new progress.Root(); + const a = root.start("task"); + const handle = progress.decodeEventStream( + progress.encodeEventStream(root), + progress.nullNode, + ); + a.end(); + root.end(42); + assert.equal(await handle, 42); + }); + + test("error rejects the decode promise", async () => { + const root = new progress.Root(); + root.start("task"); + const handle = progress.decodeEventStream( + progress.encodeEventStream(root), + progress.nullNode, + ); + root.error(new Error("compute exploded")); + await assert.rejects(handle, /compute exploded/); + }); + + test("custom events pass through", async () => { + const root = new progress.Root(); + root.start("task"); + const handle = progress.decodeEventStream( + progress.encodeEventStream(root), + progress.nullNode, + ); + const got: string[] = []; + handle.on("meow", (s) => void got.push(s)); + root.emit("meow", "purr"); + root.end(1); + assert.equal(await handle, 1); + assert.deepEqual(got, ["purr"]); + }); + + test("a stream that closes without end rejects", async () => { + const encoded = new ReadableStream({ + start(c) { + c.enqueue([-1]); + c.close(); + }, + }); + const handle = progress.decodeEventStream(encoded, progress.nullNode); + await assert.rejects(handle, /Stream terminated early/); + }); + + test("events after a long idle gap are not throttled", async () => { + let t = 0; + const delays: number[] = []; + const root = new progress.Root({ + now: () => t, + delay: (ms) => (delays.push(ms), async.delay(0)), + }); + progress.encodeEventStream(root); + t = 1000; + // regression: the throttle used to wait `idle + throttleMs` instead of + // `throttleMs - idle`, so this would schedule a ~1033ms delay + root.start("x"); + assert.ok( + delays.every((ms) => ms <= 1000 / 30 + 1), + `delays: ${delays.join(", ")}`, + ); + }); + + test("byte stream round trip with end", async () => { + const root = new progress.Root(); + const a = root.start("task", { total: 3 }); + const handle = progress.decodeByteStream( + progress.encodeByteStream(root), + progress.nullNode, + ); + a.inc(); + a.end(); + root.end("done"); + assert.equal(await handle, "done"); + }); +}); + +test("child truncation ends with a newline", () => { + const root = new progress.Root(); + const a = root.start("many"); + for (let i = 0; i < 55; i += 1) a.start(`item ${i}`); + root.start("after"); + const lines = ansi.strip(progress.formatAnsi(0, root.active)).split("\n"); + const trunc = lines.indexOf("└─ [5 more]"); + assert.notEqual(trunc, -1, "expected a truncation indicator"); + // regression: the indicator had no trailing newline, so the next top-level + // node rendered on the same line + assert.equal(lines[trunc + 1], "⠋ after"); }); // ## value formatters @@ -294,6 +442,7 @@ type EncodedKey = typeof progress.internals.EncodedKey; import assert from "node:assert/strict"; import { describe, test, vi } from "vitest"; import { UNWRAP } from "./assert.ts"; +import * as async from "./async.ts"; import * as progress from "./progress.ts"; import * as stream from "./stream.ts"; import * as ansi from "./string/ansi.ts"; diff --git a/lib/progress.ts b/lib/progress.ts index 2ede02db0bddbcffd4f0720926e72847abe08ef8..1d32fdb4e04fc83895234b05e90f29b4cfbc5a58 100644 --- a/lib/progress.ts +++ b/lib/progress.ts @@ -622,12 +622,9 @@ function endNode(owner: Root, state: Internal) { state.children.forEach((child) => endNode(owner, child)); state.detached = true; globalKeyPool.recycle(state.key); - let title = state.text; if (state.parent) { const i = state.parent.children.indexOf(state); ASSERT(i !== -1); - let p: Internal | null = state; - while (p = p.parent) title = p.text + " / " + title; state.parent.children.splice(i, 1); } owner.emit("node-end", state); @@ -732,7 +729,7 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { } if (truncated) { out += depth.map((x) => x ? box.line : " ").join("") + box.langle; - out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`); + out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`) + "\n"; } return out; } @@ -793,8 +790,11 @@ export function attachToScreen( format: ({ now }) => formatAnsi(now, root.active), }) ?? null; if (!widget) return; - widget.fps = items.some((x) => x.showTotal !== false && x.total > 0) + // scan for spinners and visible estimations + widget.fps = items.some((x) => !x.hidden && !(x.showTotal !== false && x.total > 0)) ? spinnerFps + : hasEstimate(items) + ? 1 : null; widget.redraw(); } else { @@ -817,7 +817,16 @@ export function attachToScreen( } })); - return ts.defer(() => stack[Symbol.dispose]); + stack.defer(() => { + widget?.stop(); + widget = null; + }); + + return ts.defer(() => stack.dispose()); +} + +function hasEstimate(nodes: readonly ReadOnlyNode[]): boolean { + return nodes.some((n) => n.estimatedTime != null || hasEstimate(n.children)); } const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n"); @@ -915,7 +924,7 @@ export function encodeEventStream< let timer: async.Cancelable | null = null; function emitSoon() { if (timer || ready.value) return; - const remaining = now() - lastEvent + throttleMs; + const remaining = lastEvent + throttleMs - now(); if (remaining > 0) { (timer = delay(remaining)).then(() => { timer = null; @@ -927,6 +936,13 @@ export function encodeEventStream< lastEvent = now(); } } + /** terminal events skip the throttle so the stream ends promptly */ + function emitNow() { + timer?.cancel(); + timer = null; + ready.value = true; + lastEvent = now(); + } stack.defer(() => { if (timer) timer.cancel(); s.changed.clear(); @@ -959,6 +975,22 @@ export function encodeEventStream< s.deleted.add(node); // handle add and remove in same frame emitSoon(); })); + // custom events pass through the stream verbatim + stack.use(root.onAny((channel, args) => { + if (rootEvents.has(channel)) return; + s.pendingEvents.push([channel as string, ...args as ts.Json[]]); + emitSoon(); + })); + stack.use(root.on("end", (result) => { + s.pendingEvents.push(["end", result]); + s.ended = true; + emitNow(); + })); + stack.use(root.on("error", (error) => { + s.pendingEvents.push(["error", errors.message(error)]); + s.ended = true; + emitNow(); + })); }, async pull(controller) { await ready.until((x) => x === true); @@ -969,10 +1001,13 @@ export function encodeEventStream< } controller.enqueue(s.getDelta(isFirst)); isFirst = false; + if (s.ended) { + controller.close(); + stack.dispose(); + } }, cancel() { stack.dispose(); - (0, performance.now)(); }, }, { highWaterMark: 0 }); } @@ -1003,11 +1038,10 @@ export function decodeEventStream< const { value, done } = await reader.read(); signal.throwIfAborted(); if (done) break; - decoder.processEvent(value); + if (decoder.processEvent(value)) hasEmittedEnd = true; } ASSERT(hasEmittedEnd, "Stream terminated early."); } catch (err) { - console.error(err); reader.cancel(err); if (!signal.aborted) reject(err); } finally { @@ -1037,6 +1071,10 @@ class Encoder< rootChildrenUpdated = true; changed = new Map>(); deleted = new Set(); + /** custom and terminal ("end"/"error") events awaiting the next delta */ + pendingEvents: StreamCustomEvent[] = []; + /** set when "end" or "error" was recorded; the stream closes after flushing */ + ended = false; constructor( root: Root, @@ -1119,8 +1157,11 @@ class Encoder< ); } + payload.push(...this.pendingEvents); + this.deleted.clear(); this.changed.clear(); + this.pendingEvents = []; this.rootChildrenUpdated = false; return payload; @@ -1182,7 +1223,6 @@ class Decoder< events: Events; resolve: (result: Result) => void; active = new Map(); - children = new Map(); pendingStart = new Map(); pendingParents = new Map(); @@ -1201,7 +1241,8 @@ class Decoder< this.active.clear(); } - processEvent(event: StreamEvent) { + /** returns whether the stream signalled "end"; throws on "error" */ + processEvent(event: StreamEvent): boolean { event = event.slice(); // delete unreferenced nodes first, since they may get re-allocated @@ -1236,6 +1277,8 @@ class Decoder< if (chunk[0] === "end") { resolving = true; resolvingValue = chunk[1]; + } else if (chunk[0] === "error") { + throw new Error(`Progress stream error: ${chunk[1]}`); } else { // custom event // @ts-expect-error TODO: typescript soundness @@ -1300,13 +1343,21 @@ class Decoder< } this.pendingParents.clear(); + // validate that every referenced child became active; a failure here + // means the stream is desynced for (const { key, children } of updateChildren) { - const node = UNWRAP(this.active.get(key)); - this.children.set( - node, - children.map((id) => UNWRAP(this.active.get(id))), - ); + UNWRAP(this.active.get(key), () => `parent ${key} not active`); + for (const id of children) { + UNWRAP(this.active.get(id), () => `child ${id} not active`); + } } + + if (resolving) { + this.reset(); + this.resolve(resolvingValue as Result); + return true; + } + return false; } startRecursive(key: EncodedKey, opts: PendingStart): Ref { @@ -1526,10 +1577,14 @@ export function decodeByteStream< async start(controller) { reader = new stream.BufferedReader(encoded.getReader()); try { - while (reader) controller.enqueue(await readStreamEvent(reader)); + // an event boundary with no further bytes is a clean end of stream + while (reader && await reader.ensureAvailableOrFalse(1)) { + controller.enqueue(await readStreamEvent(reader)); + } + if (!cancelled) controller.close(); } catch (e) { if (!cancelled) { - reader.cancel(e); + reader?.cancel(e); throw e; } } finally { @@ -1802,6 +1857,7 @@ export const internals: { import { ASSERT, UNWRAP } from "./assert.ts"; import * as async from "./async.ts"; import * as bytes from "./bytes.ts"; +import * as errors from "./error.ts"; import { Events } from "./Events.ts"; import * as log from "./log.ts"; import * as stack from "./log/stack.ts"; diff --git a/lib/string.test.ts b/lib/string.test.ts index ddf66e2b99d6320c1e037be33612c70a005df4fd..94314b4decb5006222ed8e62a649301c0350902f 100644 --- a/lib/string.test.ts +++ b/lib/string.test.ts @@ -3,10 +3,12 @@ test("countNewlines", () => { assert.equal(string.countNewlines(""), 0); }); test("escapeRegExp", () => { - assert.equal( - string.escapeRegExp("delete * from users where id = $id"), - "delete \\* from users where id = \\$id", - ); + // the exact escape encoding differs between the native `RegExp.escape` + // (hex escapes) and the fallback (backslashes); assert the behavior + const input = "delete * from users where id = $id"; + const re = new RegExp(`^${string.escapeRegExp(input)}$`); + assert.ok(re.test(input)); + assert.ok(!re.test("delete x from users where id = yid")); }); test("escapeShellArgument", () => { // escapeShellArgument leaves it alone when the input is trivial diff --git a/lib/string/ansi.ts b/lib/string/ansi.ts index a80d471a0d93a1e3ae3a5466104c704ab8d6ef74..39d642fe23ade406fab8485f66da4464fda8b371 100644 --- a/lib/string/ansi.ts +++ b/lib/string/ansi.ts @@ -164,6 +164,8 @@ export const clearToEndOfLine: string = "\x1b[K"; export const clearToStartOfLine: string = "\x1b[1K"; /** clear the entire line that the cursor is on without moving it. */ export const clearFullLine: string = "\x1b[2K"; +/** clear from and including the current cell to the end of the screen. */ +export const clearToEndOfScreen: string = "\x1b[0J"; /** * Begin Synchronized Output @@ -257,6 +259,7 @@ export function debugAnsi(text: string): string { "clearToEndOfLine", "clearToStartOfLine", "clearFullLine", + "clearToEndOfScreen", "syncStart", "syncEnd", "bold", @@ -292,14 +295,9 @@ export function widthInTerminal(str: string): number { width += 1; continue; } + // the remainder of the string is measured with the segmenter, which + // handles ascii correctly via segmentWidth (controls are zero-width). for (const { segment } of segmenter.segment(str.slice(i))) { - if (segment.length === 1) { - if (code < 32) continue; - if (code < 127) { - width += 1; - continue; - } - } width += segmentWidth(segment); } return width; diff --git a/lib/testing.ts b/lib/testing.ts index 9d1e8cca21a14559938428de5336761ec2f35c6f..29307f1efbc584dcf7b082e00ed8d249965627a5 100644 --- a/lib/testing.ts +++ b/lib/testing.ts @@ -114,7 +114,7 @@ export class SyncPromise implements Promise { export class FakeTimers { time = 0; entries: Array<{ - duration: number; + deadline: number; resolve: () => void; src: stack.Frame[]; }> = []; @@ -124,21 +124,23 @@ export class FakeTimers { }; delay: typeof async.delay = (ms) => { const src = stack.capture(2); + let entry: (typeof this.entries)[number]; return async.makeCancelable( new SyncPromise((resolve) => { - ASSERT(this.entries.length === 0); - this.entries.push({ - duration: ms, - resolve, - src, - }); + entry = { deadline: this.time + ms, resolve, src }; + this.entries.push(entry); }), () => { - ASSERT(this.entries.length === 1); - this.entries.length = 0; + const i = this.entries.indexOf(entry); + if (i !== -1) this.entries.splice(i, 1); }, ); }; + + /** the pending timer that fires soonest */ + next(): (typeof this.entries)[number] | undefined { + return this.entries.toSorted((a, b) => a.deadline - b.deadline)[0]; + } } export function removeUndefinedKeys(obj: unknown): unknown { @@ -176,12 +178,16 @@ export class MockScreen implements Disposable, log.WidgetHost { now: log.WidgetHost["now"]; hasTerminalLock: null | "locked" | "temporary-unlock" = null; + #sizeObservers = new Set<(size: { columns: number; rows: number }) => void>(); static sync(text: string[]): string { return ansi.syncStart + text.join("") + ansi.syncEnd; } - constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) { + constructor({ temporaryUnlocking, outputSharesScreen }: { + temporaryUnlocking?: boolean; + outputSharesScreen?: boolean; + } = {}) { const callerFile = UNWRAP(stack.capture()[0]); const host = log.createTerminalWidgetHost({ lockTerminal: () => { @@ -210,8 +216,10 @@ export class MockScreen implements Disposable, log.WidgetHost { content, }); }, - getSize: () => { - return this; + observeSize: (callback) => { + callback({ columns: this.columns, rows: this.rows }); + this.#sizeObservers.add(callback); + return () => void this.#sizeObservers.delete(callback); }, temporaryUnlock: temporaryUnlocking ? () => { @@ -231,6 +239,7 @@ export class MockScreen implements Disposable, log.WidgetHost { now: this.timers.now, delay: this.timers.delay, color: true, + outputSharesScreen, }); this.writeOutput = host.writeOutput; this.writeError = host.writeError; @@ -240,18 +249,26 @@ export class MockScreen implements Disposable, log.WidgetHost { this.now = host.now; } + /** simulate a terminal resize (SIGWINCH) */ + resize(columns: number, rows: number = this.rows) { + this.columns = columns; + this.rows = rows; + this.#sizeObservers.forEach((cb) => cb({ columns, rows })); + } + expectNone() { ASSERT(this.timers.entries.length === 0); } expectWithoutConsume(ms: number) { const wait = UNWRAP( - this.timers.entries[0], + this.timers.next(), () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o", ); + const duration = wait.deadline - this.timers.time; ASSERT( - ms === wait.duration, - `expected ${ms}ms to pass, got ${wait.duration}, from:\n${ + ms === duration, + `expected ${ms}ms to pass, got ${duration}, from:\n${ wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n") }`, ); @@ -265,8 +282,8 @@ export class MockScreen implements Disposable, log.WidgetHost { }) { if (ms != null) { const wait = this.expectWithoutConsume(ms); - this.timers.entries.shift(); - this.timers.time += wait.duration; + this.timers.entries.splice(this.timers.entries.indexOf(wait), 1); + this.timers.time = wait.deadline; wait.resolve(); } else { ASSERT(this.timers.entries.length === 0);