From 333b8a7a5c68ab8ab1917a6192022b0f96c46823 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 12 Jun 2026 18:21:02 -0700 Subject: [PATCH] feat(lib/log): writeError will actually write to stderr closes #93 --- lib/log.test.ts | 77 ++++++++++++++++++++++ lib/log.ts | 146 ++++++++++++++++++++++++++++-------------- lib/readme.changes.md | 1 + 3 files changed, 176 insertions(+), 48 deletions(-) diff --git a/lib/log.test.ts b/lib/log.test.ts index ef1cd82c85dd60a80c3676c3f5c2bd768e8c1f53..4a794f869cebf51d68c3c1d7c43923c6c51f409a 100644 --- a/lib/log.test.ts +++ b/lib/log.test.ts @@ -758,6 +758,83 @@ describe("log widgets", () => { host.cancel(); }); + test("writeError routes to the interactive stream in order", () => { + const host = new testing.MockScreen(); + host.writeOutput("out 1\n"); + host.writeError("err 1\n"); + host.writeOutput("out 2\n"); + host.expectFrame(0, { + stdout: "out 1\nout 2\n", + stderr: "err 1\n", + merged: "out 1\nerr 1\nout 2\n", + }); + host.cancel(); + }); + + test("error text moves widgets like output", () => { + const host = new testing.MockScreen(); + using _ = host.startWidget({ format: () => "w" }); + host.expectFrame(0, { stderr: testing.MockScreen.sync(["w\n"]) }); + host.writeError("oops\n"); + host.expectFrame(0, { + stdout: "", + stderr: testing.MockScreen.sync([ + ansi.cursorUp(1), + ansi.clearFullLine, + "oops\n", + "w\n", + ]), + }); + host.cancel(); + }); + + test("mixed stream partial lines share cursor math", () => { + const host = new testing.MockScreen(); + using _ = host.startWidget({ format: () => "w" }); + host.expectFrame(0, { stderr: testing.MockScreen.sync(["w\n"]) }); + host.writeOutput("a"); + host.writeError("b"); + host.expectFrame(0, { + stdout: "a", + merged: testing.MockScreen.sync([ + ansi.cursorUp(1), + ansi.clearFullLine, + "a", + "b", + "\n", + "w\n", + ]), + }); + // the continuation point is column 2, covering both partial chunks + host.writeOutput("c\n"); + host.expectFrame(0, { + merged: testing.MockScreen.sync([ + ansi.cursorUp(2) + ansi.cursorRight(2), + "c\n", + "w\n", + ]), + }); + host.cancel(); + }); + + test("error text stays on screen when output is redirected", () => { + const host = new testing.MockScreen({ outputSharesScreen: false }); + using _ = host.startWidget({ format: () => "w" }); + host.expectFrame(0, { stderr: testing.MockScreen.sync(["w\n"]) }); + host.writeOutput("to the file\n"); + host.writeError("to the screen\n"); + host.expectFrame(0, { + stdout: "to the file\n", + stderr: testing.MockScreen.sync([ + ansi.cursorUp(1), + ansi.clearFullLine, + "to the screen\n", + "w\n", + ]), + }); + host.cancel(); + }); + test("widgets start below a partial line", () => { const host = new testing.MockScreen(); let text = ""; diff --git a/lib/log.ts b/lib/log.ts index a2726a454e33dfeeb98acb74342f4e70e60f2b8d..3ea90d86c914c2e2bb97cc76457e1cb1cb1b93a2 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -238,14 +238,14 @@ export function writeOutput(text: string) { globalWidgetHost().writeOutput(text); } -// TODO: -// /** -// * no built-in prefix, formatting, or newlline. ensures the text does not interweave. -// * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}. -// */ -// export function writeError(text: string) { -// globalWidgetHost().writeError(text); -// } +/** + * like {@linkcode writeOutput}, but the text is written to the error stream + * (stderr in node.js). relative ordering between output and error text is + * preserved through the shared flush buffer. + */ +export function writeError(text: string) { + globalWidgetHost().writeError(text); +} /** write a {@linkcode Message} object directly. */ export function writeMessage(m: Message) { @@ -390,7 +390,11 @@ export interface TerminalWidgetHostOptions { * interface to communicate everything about the terminal state correctly. */ export interface TerminalLock { - /** recieves ANSI escape sequences for interactive data (should flush immediately) */ + /** + * recieves ANSI escape sequences for interactive data, as well as error log + * content from `writeError` (should flush immediately). the interactive + * stream and the error stream are the same: stderr. + */ writeInteractive(text: string): void; /** recieves log content from `write` (pre-buffered; should flush immediately) */ writeOutput(text: string): void; @@ -460,7 +464,12 @@ export function createTerminalWidgetHost( let locks = 0; let redrawTime = 0; let lastFlush = 0; - let buffer = ""; + // log text awaiting a flush. error text shares the queue so that relative + // write order is preserved, but flushes to the interactive stream (stderr) + // and always lands on the widget screen, while plain output rows only + // count when `outputSharesScreen`. consecutive same-stream writes merge + // into one chunk. + let buffer: { text: string; err: boolean }[] = []; // 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. @@ -536,7 +545,7 @@ export function createTerminalWidgetHost( } // 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) { + if (widgets.length === 0 && terminal && !timer && !buffer.length) { lines = []; closeTerminal(); } @@ -550,21 +559,24 @@ export function createTerminalWidgetHost( if (!lines.length && !widgets.length) { // a redraw can get scheduled with nothing to write (e.g. widgets torn // down before the timer fired); it is a no-op, not an error - if (!buffer) return; + if (!buffer.length) return; needsToRestoreCursor = false; needsToSaveCursor = false; - if (!terminal && writeOutputTemporaryLock) { - writeOutputTemporaryLock(buffer); + if ( + !terminal && writeOutputTemporaryLock && !buffer.some((c) => c.err) + ) { + const text = buffer.map((c) => c.text).join(""); + buffer = []; + writeOutputTemporaryLock(text); + if (outputOnScreen) trackPartialWidth(text); } else { - acquireTerminal().writeOutput(buffer); + trackPartialWidth(flushChunks(acquireTerminal())); } if (hasSyncStart) { acquireTerminal().writeInteractive(ansi.syncEnd); hasSyncStart = false; } closeTerminal(); - trackPartialWidth(buffer); - buffer = ""; return; } @@ -624,10 +636,8 @@ export function createTerminalWidgetHost( needsToRestoreCursor = false; lines = []; } - if (buffer) { - term.writeOutput(buffer); - trackPartialWidth(buffer); - buffer = ""; + if (buffer.length) { + trackPartialWidth(flushChunks(term)); } if (hasSyncStart) { term.writeInteractive(ansi.syncEnd); @@ -639,15 +649,20 @@ export function createTerminalWidgetHost( return; } - if (buffer && !outputOnScreen) { + // concatenation of the buffered text that lands on the widget screen, + // which is what the cursor math must account for + const screenText = buffer + .filter((c) => c.err || outputOnScreen) + .map((c) => c.text) + .join(""); + if (buffer.length && screenText === "") { // 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 = ""; + flushChunks(term); } - if (buffer) { + if (buffer.length) { // when writing a buffer alongside widgets, the screen may look like this // > [existing log] // > [optional partial line] @@ -659,7 +674,7 @@ export function createTerminalWidgetHost( // 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); + const span = measureTerminalSpan(screenText, 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. @@ -689,8 +704,8 @@ export function createTerminalWidgetHost( : "") : ""), ); - // then write output lines on standard out - term.writeOutput(buffer); + // then write the buffered log content to its streams + flushChunks(term); 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 @@ -753,17 +768,37 @@ export function createTerminalWidgetHost( needsToRestoreCursor ||= needsToSaveCursor; needsToSaveCursor = false; lines = newWidgetLines; - buffer = ""; } /** - * update `partialWidth` after writing `text` to the output. rows written - * off-screen never displace the widget block, so they are not tracked. + * write every buffered chunk to its stream in order, returning the + * concatenation of the chunks that landed on the widget screen. error + * chunks flush through `writeInteractive` since the interactive stream is + * the error stream; plain output rows land on screen only when + * `outputSharesScreen`. */ - function trackPartialWidth(text: string) { - if (!outputOnScreen) return; - const i = text.lastIndexOf("\n"); - partialWidth = ansi.widthInTerminal(text.slice(i + 1)) + function flushChunks(term: TerminalLock): string { + let screen = ""; + for (const chunk of buffer) { + if (chunk.err) { + term.writeInteractive(chunk.text); + screen += chunk.text; + } else { + term.writeOutput(chunk.text); + if (outputOnScreen) screen += chunk.text; + } + } + buffer = []; + return screen; + } + + /** + * update `partialWidth` after `screenText` (already filtered to the chunks + * that landed on the widget screen) was written. + */ + function trackPartialWidth(screenText: string) { + const i = screenText.lastIndexOf("\n"); + partialWidth = ansi.widthInTerminal(screenText.slice(i + 1)) + (i === -1 ? partialWidth : 0); } @@ -807,24 +842,35 @@ export function createTerminalWidgetHost( hasSyncStart = shortTermDrawLock; } if (buffer.length > 0) { - if (widgets.length === 0 && !terminal && writeOutputTemporaryLock) { - writeOutputTemporaryLock(buffer); + if ( + widgets.length === 0 && !terminal && writeOutputTemporaryLock + && !buffer.some((c) => c.err) + ) { + const text = buffer.map((c) => c.text).join(""); + buffer = []; + writeOutputTemporaryLock(text); + if (outputOnScreen) trackPartialWidth(text); } else { - acquireTerminal().writeOutput(buffer); + trackPartialWidth(flushChunks(acquireTerminal())); if (widgets.length === 0) closeTerminal(); } - trackPartialWidth(buffer); - buffer = ""; } } + /** append a write to the flush buffer, scheduling the flush */ + function bufferChunk(chunk: string, err: boolean) { + const last = buffer[buffer.length - 1]; + if (last && last.err === err) last.text += chunk; + else buffer.push({ text: chunk, err }); + redrawSoon(0); + } + return { writeOutput(chunk) { - if (chunk) buffer += chunk, redrawSoon(0); + if (chunk) bufferChunk(chunk, false); }, writeError(chunk) { - // TODO: write to stderr. when this was introduced it was not a regression from v3 - if (chunk) buffer += chunk, redrawSoon(0); + if (chunk) bufferChunk(chunk, true); }, getDrawLock(mode) { if (rendering) ASSERT(locks === 0); @@ -1375,11 +1421,15 @@ const globalLog = /* @__PURE__ */ (() => if (global.writeMessage) { global.writeMessage(m); } 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)); + // info/debug levels land on stdout, warnings and errors on stderr; + // colors are keyed off the destination stream + const err = (m.level ?? "info") !== "info" && m.level !== "debug"; + globalWidgetHost()[err ? "writeError" : "writeOutput"]( + formatAnsiMessage( + m, + (err ? node.process.stderr : node.process.stdout).isTTY, + ), + ); } else { let { level = "info", [originalLogArgs]: args = [m.text], scope } = m; if (scope) { diff --git a/lib/readme.changes.md b/lib/readme.changes.md index 9fe2faa48603983de2d15cd200287f959700070c..79fe6eb3150dc26a6385b69a18f40865ddc595ae 100644 --- a/lib/readme.changes.md +++ b/lib/readme.changes.md @@ -17,6 +17,7 @@ - `mime`'s database contains `.eot` for embedded opentype fonts. - `async.deferred` +- `log.writeError` writes to stderr with proper logic ## v4 -- 2.54.0