From 3e15ccfda871800cf412a0735e5cd9fd30926843 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 21 Mar 2026 18:09:56 -0700 Subject: [PATCH] feat(lib/log): global awareness of the terminal lock libraries should not call global side effects, but for `log` and its widget feature, it is a must to call `process.stderr.write`. so when it is a library's job to touch the terminal, it has an important duty to handle all edge cases so execution and output is safe. this feature uses a symbol property on `globalThis` to allow `@clo/lib/log` to communicate with other bundled instances of itself, even across versions. it now uses this channel to ensure that: - widgets from different implementations do not interweaving with logs or each other. `replaceGlobalWidgetHost` acts on this singleton. - calling `log.tee` recieves all other globally written messages - calling `replaceGlobalMessageDestination` works - calling `replaceGlobalFormatFunction` works APIs that do not have replacer functions cannot be affected by this global sync system. to do this, most of the internal API was reworked, so that it could be marked "stable forever". i am happy with the frozen interfaces of `Message`, `WidgetHost`, and `DrawLock`, and very glad that many APIs are not caught in this (`TerminalWidgetHostOptions` for example). one example of how this can be used is with a custom runtime or TUI framework (such as `ink`). even if there is a weird dependency chain loading an old version of `@clo/lib` and calling a global directly, you can install the latest version of the library into your app and provide a proper binding via `replaceGlobalWidgetHost`. (for `ink`, that may mean providing a component where the widgets are rendered into, and integrating it with the React rendering pipeline) --- lib/async.ts | 1 + lib/log.ts | 655 ++++++++++++++++++++++++++++-------------- lib/log/stack.ts | 2 +- lib/node.ts | 44 +-- lib/package.json | 4 + lib/progress.ts | 73 ++--- lib/readme.changes.md | 27 +- lib/testing.ts | 60 ++-- 8 files changed, 569 insertions(+), 297 deletions(-) create mode 100644 lib/package.json diff --git a/lib/async.ts b/lib/async.ts index 4c9a79047c81cd16dbc2eab0a5c833a17f312432..060125c7a5f7561376ad3927b414876554e31f53 100644 --- a/lib/async.ts +++ b/lib/async.ts @@ -235,6 +235,7 @@ export function makeCancelable( /** wait `ms` milliseconds, then resolve. can be cancelled. */ export function delay(ms: number): Cancelable { + if (ms === Infinity) return makeCancelable(new Promise(() => {}), () => {}); let t: ts.Timer | null = null; const maxTimer = 0x7FFFFFFF; return makeCancelable( diff --git a/lib/log.ts b/lib/log.ts index 371f23c635e10244c1f032856a9e00ad6fcb832c..73350f333e2529338c307cb5791aa54f5d695d1f 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -1,8 +1,11 @@ /** - * by using `lib/log.ts`, an application gets easy scoped logging as well as - * integration with terminal widgets such as `lib/progress.ts`. even when these + * by using `@clo/lib/log.ts`, an application gets easy scoped logging as well + * as integration with terminal widgets such as `progress.ts`. even when these * widgets are active, using the logging interface is optional; global I/O with - * `console.*` and `process.std{out/err}` are automatically patched to play nice. + * `console.*` and `process.std{out/err}` are automatically patched to play nice + * so nearly any attempts at writing to the terminal should display fine. + * additionally, the clover library writes to a global symbol to communicate with + * other copies of this library (even across versions) to coordinate drawing. * * the pattern for using this module is to shadow the global `console` with a * per-file logging scope, which makes it impossible to use the wrong logger. @@ -20,7 +23,7 @@ * import * as console from "@clo/lib/log"; * ``` * - * now, the code reads familiarly (`console.log` is universally understood), + * now code reads familiarly (`console.log` is universally understood), * but the output is organized into relevant scopes. * * in addition to static log messages, a system for interactive I/O via the @@ -88,19 +91,31 @@ export interface RootScope extends Scope { } export const originalLogArgs = Symbol("originalLogArgs"); + +/** + * a message written in a log scope. + * this API will never be altered in a breaking way. + */ export interface Message { - level: "error" | "warn" | "info" | "debug"; - /** ANSI-styled unicode text */ + /** ANSI-styled text */ text: string; /** datetime in milliseconds since UNIX epoch */ time: number; + /** + * type of message, if known. + * @default info + */ + level?: MessageLevel; /** scope name */ scope?: string | null; - /** captured stack. */ + /** captured stack, if available */ stack?: stack.Frame[]; /** arbitrary data from the logging source. */ custom?: Partial>; - /** print a newline at the end of this log line? */ + /** + * if there is a newline at the end of this log line. + * @default true + */ newline?: boolean; /** * original logging arguments, if present. this field is indexed by a symbol @@ -109,6 +124,8 @@ export interface Message { */ [originalLogArgs]?: unknown[]; } +/** this API will never be altered in a breaking way. */ +type MessageLevel = "error" | "warn" | "info" | "debug"; // these functions implement `Scope` for the module's namespace. that means if // a function takes in `Scope`, this file's namespace satisfies that. @@ -143,29 +160,48 @@ export function scoped(name: string): Scope { } /** redirect all log messages to another writer */ export function tee(destination: DispatchFunction): ts.Dispose { - return globalLog.tee(destination); + global.tees.add(destination); + return ts.defer(() => global.tees.delete(destination)) } /** replace the default message writer */ export function replaceGlobalMessageDestination(destination: DispatchFunction) { - globalOutputFunction = destination; + global.writeMessage = destination; } -/** replace the default interactive widget host */ -export function replaceGlobalWidgetHost(widgetHost: WidgetHost) { - globalWidgetHost = widgetHost; +/** + * replace the default interactive widget host. this applies for all separately + * installed copies of the library, across versions. once the widget host has + * been activated, it must be preserved forever. + */ +export function replaceGlobalWidgetHost(host: WidgetHost) { + if (global.widget?.frozen) { + throw new Error( + `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`, + ); + } + global.widget = { + frozen: false, + source: stack.capture()[0]?.file ?? + ("untracable call to replaceGlobalWidgetHost in " + import.meta.url), + host, + version: 0, + }; } -/** replace the default message formatter */ -export function replaceGlobalFormatFunction( +/** + * replace the default message formatter. does not affect browsers because that + * code path does not use `formatAnsiMessage` + */ +export function replaceGlobalFormatAnsiMessage( format: (msg: Message, colors: boolean) => string, ) { - globalMessageFormatFunction = format; + global.formatAnsiMessage = format; } /** includes the trailing newline for standard log messages */ -export function formatMessage(msg: Message, colors: boolean): string { - return globalMessageFormatFunction(msg, colors); +export function formatAnsiMessage(msg: Message, colors: boolean): string { + return (global.formatAnsiMessage ?? defaultFormatAnsiMessage)(msg, colors); } /** @@ -173,6 +209,9 @@ export function formatMessage(msg: Message, colors: boolean): string { * of the log. this can be used to implement status bars, progress * indicators, and other human I/O. only 'format' is required. * + * returns `null` if the terminal is not interactive or the global renderer is + * incapable of displaying this log (such as in a browser) + * * ```ts * using _ = log.startWidget({ * format: (now) => `It is ${new Date().toString()} right now\n` @@ -185,18 +224,29 @@ export function formatMessage(msg: Message, colors: boolean): string { * import * as async from "@clo/lib/async.ts"; * ``` */ -export function startWidget(widget: Widget): ts.Dispose { - return globalWidgetHost.startWidget(widget); +export function startWidget( + widget: T, +): WidgetInstance | null { + return globalWidgetHost().startWidget(widget); } /** - * no built-in prefix or formatting. ensures the text does not interweave. data - * will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}. + * 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 write(text: string) { - globalWidgetHost.write(text); +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); +// } + /** write a {@linkcode Message} object directly. */ export function writeMessage(m: Message) { globalLog.writeMessage(m); @@ -211,7 +261,7 @@ export function writeMessage(m: Message) { * `"short"` which will allow more optimized use of ansi synchronization codes. */ export function getDrawLock(mode: "long" | "short"): DrawLock { - return globalWidgetHost.getDrawLock(mode); + return globalWidgetHost().getDrawLock(mode); } /** @@ -227,17 +277,25 @@ export function getDrawLock(mode: "long" | "short"): DrawLock { * you most likely do not need to call this API. */ export function ensureGlobalsArePatched(): ts.Dispose { - return globalWidgetHost.startWidget({ format: () => "" }); + if (global.widget?.frozen && global.widget?.version !== version) { + throw new Error( + `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`, + ); + } + const w = globalWidgetHost().startWidget?.({ format: () => "" }); + return ts.defer(() => w?.stop()); } +/** + * a non-exclusive lock to drawing + * this API will never be altered in a breaking way. + */ export interface DrawLock { /** * decides if widget drawing requires an extra newline, which is needed if * there is extra text on the line that the lock is being released on. */ - release( - cursorPosition: "cursor-start-of-line" | "cursor-middle-of-line", - ): void; + release(endState: "cursor-start-of-line" | "cursor-middle-of-line"): void; /** Assumes worst case `cursor-middle-of-line` */ [Symbol.dispose](): void; } @@ -246,29 +304,53 @@ export function headlessScope(dispatch: DispatchFunction): RootScope { return new ScopeImpl(dispatch); } -/** see {@linkcode startWidget} */ -export interface Widget { +/** + * see {@linkcode startWidget} + * this API will never be altered in a breaking way. + */ +export interface WidgetOptions { /** * return the widget's text. return null to detach the widget. * may get called more often than the specified `fps`. * supports color codes but not ansi cursor movements. */ - format( - now: ReturnType, - ): - | string - | null; - /** 'null' to never update (use 'onChange') */ - fps?: - | number - | null; /** Subscribe to manual widget updates. Call `rerender` when needed. */ - onChange?(rerender: () => void): () => void; - /** listen for keyboard events. */ - onKey?(key: string): void; + format(ctx: WidgetFormatContext): string | { text: string } | null; + /** 'null' to never update automatically */ + fps?: number | null; +} + +/** + * control for a widget (see {@linkcode startWidget}) + * this API will never be altered in a breaking way. + */ +export interface WidgetInstance { + /** the provided widget options */ + options: T; + get fps(): number | null; + set fps(fps: number | null); + /** schedules a new frame to be drawn as soon as possible */ + redraw(): void; + /** remove the widget from the screen */ + stop(): void; + /** alias of `stop` */ + [Symbol.dispose](): void; +} + +/** + * this API will never be altered in a breaking way. + */ +export interface WidgetFormatContext { + /** the current time according to the widget host */ + now: ReturnType; + /** advisory */ + width: number; + /** advisory */ + height: number; + host?: WidgetHost; } /** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */ -export interface WidgetHostOptions { +export interface TerminalWidgetHostOptions { /** * an exclusive lock on the terminal is held whenever widgets are active. a * secondary purpose of this is to instrument/deinstrument other code to @@ -290,10 +372,12 @@ export interface WidgetHostOptions { now: () => ReturnType; /** after resolving, `now()` should have increased by the delay time */ delay: typeof async.delay; + /** is there color support? */ + color: boolean; } /** - * When `@clo/lib` requests a lock on the terminal, the adapter provides this + * when `@clo/lib` requests a lock on the terminal, the adapter provides this * interface to communicate everything about the terminal state correctly. */ export interface TerminalLock { @@ -310,34 +394,46 @@ export interface TerminalLock { close(): void; } -/** an implementation of an ANSI-based widget host */ +/** + * an implementation of an ANSI-based widget host. + * this structure must not be altered in a breaking way. + */ export interface WidgetHost { - /** see the top-level {@linkcode writeLine} function */ - write(text: string): void; + /** see the top-level {@linkcode writeOutput} function */ + writeOutput(text: string): void; + /** see the top-level {@linkcode writeError} function */ + writeError(text: string): void; /** see the top-level {@linkcode getDrawLock} function */ getDrawLock(mode: "long" | "short"): DrawLock; /** see the top-level {@linkcode startWidget} function */ - startWidget(widget: Widget): ts.Dispose; + startWidget(widget: T): WidgetInstance | null; /** stop all widgets and remove all timers. */ cancel(): void; /** generic delay function */ delay?: typeof async.delay; /** generic now function */ now?: typeof performance.now; + /** + * what does this widget host support? + * - color: ANSI escape sequences are displayed and not stripped + * - widget: `startWidget` can meaninglyful display the widget + */ + capabilities: ReadonlyArray<"widget" | "color">; } /** @internal state */ interface WidgetState { frameTime: number; next: number; - unsub: (() => void) | null; } /** * terminal widget rendering is done by specifying all system APIs up front in * an interface, creating an instance of the "widget host". */ -export function createWidgetHost(env: WidgetHostOptions): WidgetHost { +export function createTerminalWidgetHost( + env: TerminalWidgetHostOptions, +): WidgetHost { const { lockTerminal, now, delay, writeOutputTemporaryLock } = env; let timer: async.Cancelable | null = null; @@ -349,7 +445,7 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { let partialLineIndex = 0; let needsToSaveCursor = false; let needsToRestoreCursor = false; - const widgets: Widget[] = []; + const widgets: WidgetOptions[] = []; const internals: WidgetState[] = []; let lines: string[] = []; let hasSyncStart = false; @@ -392,14 +488,19 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { let newWidgetLines: string[] = []; let next = Infinity; for (let w = 0, { length } = widgets; w < length; w += 1) { - const outText = UNWRAP(widgets[w]).format(lastFlush); - if (!outText) { + const out = UNWRAP(widgets[w]).format({ + now: lastFlush, + width: columns, + height: rows, + }); + if (!out) { widgets.splice(w, 1); - UNWRAP(internals.splice(w, 1)[0]).unsub?.(); + UNWRAP(internals.splice(w, 1)[0]); w -= 1; length -= 1; continue; } + const outText = typeof out === "string" ? out : out.text; const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); if (rowsLeft === 1) break; const lines = outText.split("\n").slice(0, rowsLeft); @@ -581,7 +682,11 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { } return { - write(chunk) { + writeOutput(chunk) { + if (chunk) buffer += chunk, redrawSoon(0); + }, + writeError(chunk) { + // TODO: write to stderr. when this was introduced it was not a regression from v3 if (chunk) buffer += chunk, redrawSoon(0); }, getDrawLock(mode) { @@ -615,27 +720,40 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { }, }; }, - startWidget(w) { - ASSERT(!widgets.includes(w), "Cannot start the same widget twice."); + startWidget(options) { + ASSERT(!widgets.includes(options), "Cannot start the same widget twice."); + let fps = options.fps ?? null; const state: WidgetState = { next: 0, - unsub: null, - frameTime: 1000 / (w.fps ?? 0), + frameTime: 1000 / (fps ?? 0), }; - widgets.push(w); + widgets.push(options); internals.push(state); - state.unsub = w.onChange?.(() => { - state.next = 0; - redrawSoon(0); - }) ?? null; redrawSoon(0); - return ts.defer(() => { - const i = widgets.indexOf(w); - if (i === -1) return; - widgets.splice(i, 1); - UNWRAP(internals.splice(i, 1)[0]).unsub?.(); - redrawSoon(0); - }); + return { + options, + get fps() { + return fps; + }, + set fps(value) { + fps = value; + state.frameTime = 1000 / (fps ?? 0); + }, + redraw() { + state.next = 0; + redrawSoon(0); + }, + stop() { + const i = widgets.indexOf(options); + if (i === -1) return; + widgets.splice(i, 1); + UNWRAP(internals.splice(i, 1)[0]); + redrawSoon(0); + }, + [Symbol.dispose]() { + this.stop(); + }, + }; }, cancel() { flushAndClear(false); @@ -643,6 +761,7 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { }, delay, now, + capabilities: env.color ? ["widget", "color"] : ["widget"], }; } @@ -732,7 +851,7 @@ const ScopeImpl = class Scope implements RootScope { }; writeMessage: (m: Message) => void = (m) => { - if (withinDispatch) return void globalOutputFunction(m); + if (withinDispatch) return void globalLog.#dispatch(m); withinDispatch = true; this.#dispatch(m); withinDispatch = false; @@ -771,26 +890,127 @@ const ScopeImpl = class Scope implements RootScope { } }; -let globalWidgetHost = node.process - ? /* @__PURE__*/ ((process: NonNullable) => { - const widget = createWidgetHost({ - lockTerminal() { - const { stdout, stderr } = process; - let disposed = false; +/** + * global integration is done in a special manner with IIFE expressions to + * ensure that the logic is removed when a bundler tree-shakes this file. + * additionally, this ensures that replacing a global function properly affects + * other installations. this is safe to do because these shared interfaces have + * a commitment to never change in a breaking way. + */ +interface GlobalCommunication { + readme: string; + /** urls / file paths */ + instances: string[]; + widget?: { + version: number; + frozen: boolean; + host: WidgetHost; + /** url / file path / line number / identifying information */ + source: string; + }; + /** overwrite the message writer */ + writeMessage?: DispatchFunction; + /** overwrite the message formatter */ + formatAnsiMessage?: (m: Message, colors: boolean) => string; + /** calls to the global `tee()` */ + tees: Set; +} - // patch calls to `process.std{out,err}` - // note: `pipe` uses managed calls to `write`, so this is plenty - const stdoutWrite = stdout.write; - const stderrWrite = stderr.write; - const stdoutEnd = stdout.end; - const stderrEnd = stderr.end; - const newStdoutWrite = stdout.write = widget.write; - const newStderrWrite = stderr.write = function ( - this: typeof stderr, - ...args - ) { - using lock = disposed ? null : widget.getDrawLock("short"); - stderrWrite.apply(stderr, args); +const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log"); +const version = 4; +let global: GlobalCommunication = /* @__PURE__ */ (() => { + const global = + (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= { + readme: "this global holds shared state for \"@clo/lib/log\", to allow different instances of itself to coordinate with each other", + instances: [], + tees: new Set, + }; + global.instances.push(import.meta.url); + return global; +})(); + +function defaultFormatAnsiMessage( + { level, scope, text, newline }: Message, + colors: boolean, +) { + if (!text) return ""; + if (newline === false) return text; + const prefix = colors + // colorful + ? `${levelToAnsi[level ?? "info"]}${ + scope ? `(${scope})` : "" + }${ansi.fgReset}${ansi.dim}:${ansi.reset} ` + // colorless + : scope + ? `${level}(${scope}): ` + : `${level}: `; + return prefix + text + "\n"; +} + +let initWidgetHost = false; +function globalWidgetHost(): WidgetHost { + if ( + global.widget && ( + initWidgetHost || + global.widget.frozen || + // By default, pick the latest version of the widget host implementation. + // This is most likely to resolve the most issues as possible. To opt out of + // this, import the desired implementation and call its + // `ensureGlobalsArePatched` or `replaceGlobalWidgetHost` method. + global.widget.version >= version + ) + ) { + initWidgetHost = true; + return global.widget.host; + } + initWidgetHost = true; + global.widget = { + frozen: false, + host: node.process + ? defaultNodeProcessWidgetHost(node.process) + : defaultFallbackWidgetHost(), + source: import.meta.url, + version, + }; + return global.widget.host; +} + +export function defaultNodeProcessWidgetHost( + process: NonNullable, + forceWidgetSupport = false, +): WidgetHost { + // fallback + if (!forceWidgetSupport && !process.stderr.isTTY) { + return { + writeOutput: (string) => process.stdout.write(string), + writeError: (string) => process.stderr.write(string), + getDrawLock: () => ({ + release() {}, + [Symbol.dispose]() {}, + }), + startWidget: () => null, + cancel: () => {}, + capabilities: [], + }; + } + + const host = createTerminalWidgetHost({ + lockTerminal() { + const { stdout, stderr } = process; + let disposed = false; + + // patch calls to `process.std{out,err}` + // note: `pipe` uses managed calls to `write`, so this is plenty + const stdoutWrite = stdout.write; + const stderrWrite = stderr.write; + const stdoutEnd = stdout.end; + const stderrEnd = stderr.end; + function patchWriteMethod( + fn: (this: T, ...args: A) => R, + ) { + return function (this: T, ...args: A) { + using lock = disposed ? null : host.getDrawLock("short"); + const ret = fn.apply(this, args); if (lock) { lock.release( (typeof args[0] === "string" @@ -800,112 +1020,137 @@ let globalWidgetHost = node.process : "cursor-middle-of-line", ); } + return ret; }; - function patchEndMethod( - fn: (this: T, ...args: A) => void, - ) { - return function (this: T, ...args: A) { - using lock = disposed ? null : widget.getDrawLock("short"); - fn.apply(this, args); - // TODO: this is not handled correctly, but nobody closes - // their fucking standard error! this likely should just - // disable the library if you call end. - if (lock) lock.release("cursor-start-of-line"); - }; - } - const newStdoutEnd = stdout.end = patchEndMethod(stdoutEnd); - const newStderrEnd = stderr.end = patchEndMethod(stderrEnd); - - // non-node runtimes will typically implement console in a way that - // doesn't use `node:process`, so it must also get patched. this is - // okay because the lock is re-enterant. - function patchSyncMethod( - fn: (this: T, ...args: A) => void, - ) { - return function (this: T, ...args: A) { - using lock = disposed ? null : widget.getDrawLock("short"); - fn.apply(this, args); - if (lock) lock.release("cursor-start-of-line"); - }; - } - const console = globalThis - .console as unknown as Record void>; - const restoreConsole: [string, old: () => void, patch: () => void][] = - []; - for (const [key, old] of Object.entries(console)) { - if (typeof old !== "function") continue; - try { - const patched = console[key] = patchSyncMethod(old); - restoreConsole.push([key, old, patched]); - } catch { /* skip */ } - } - - return { - writeOutput: (string) => stdoutWrite.call(stderr, string), - writeInteractive: (string) => stderrWrite.call(stderr, string), - getSize: () => process.stderr, - temporarilyUnlock() { - // no action needed - }, - 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 (stdout.end === newStdoutEnd) stdout.end = stdoutEnd; - if (stderr.end === newStderrEnd) stdout.end = stderrEnd; - for (const [key, old, patched] of restoreConsole) { - if (console[key] === patched) console[key] = old; - } - }, + } + const newStdoutWrite = stdout.write = patchWriteMethod(stdoutWrite); + const newStderrWrite = stderr.write = patchWriteMethod(stderrWrite); + function patchEndMethod( + fn: (this: T, ...args: A) => R, + ) { + return function (this: T, ...args: A) { + using lock = disposed ? null : host.getDrawLock("short"); + const ret = fn.apply(this, args); + // TODO: this is not handled correctly, but people rarely close their + // standard I/O! this likely should just disable the library if you + // call end. i'm not particularly worried. + if (lock) lock.release("cursor-start-of-line"); + return ret; }; - }, - now: () => performance.now(), - delay: async.delay, - }); - process.addListener("beforeExit", () => widget.cancel()); - process.addListener("exit", () => widget.cancel()); + } + const newStdoutEnd = stdout.end = patchEndMethod(stdoutEnd); + const newStderrEnd = stderr.end = patchEndMethod(stderrEnd); - return widget; - })(node.process) - : /* @__PURE__ */ ((warned = false) => { - return { - write: (line: string) => console.log(line), - getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }), - startWidget: (w: Widget) => { - if (!warned) { - console.warn( - '"@clo/lib/log.ts"\'s startWidget was called in an environment ' + - "that does not support the Node.js 'process' API. Widgets " + - "will not be visible.", - ); - warned = true; - } - const close = w.onChange?.(() => {}); - return ts.defer(close ?? (() => {})); - }, - cancel: () => {}, - }; - })(); + // non-node runtimes will typically implement console in a way that + // doesn't use `node:process`, so it must also get patched. this is + // okay because the lock is re-enterant. + function patchSyncMethod( + fn: (this: T, ...args: A) => void, + ) { + return function (this: T, ...args: A) { + using lock = disposed ? null : host.getDrawLock("short"); + fn.apply(this, args); + if (lock) lock.release("cursor-start-of-line"); + }; + } + const console = globalThis + .console as Console & Record void>; + const restoreConsole: [string, old: () => void, patch: () => void][] = []; + for (const [key, old] of Object.entries(console)) { + if (typeof old !== "function") continue; + try { + const patched = console[key] = patchSyncMethod(old); + restoreConsole.push([key, old, patched]); + } catch { /* skip */ } + } -export function simpleNodeProcessWidgetHost(process: node.Process) { - return createWidgetHost({ - lockTerminal() { return { - writeOutput: (string) => process.stdout.write(string), - writeInteractive: (string) => process.stderr.write(string), + writeOutput: (string) => stdoutWrite.call(stderr, string), + writeInteractive: (string) => stderrWrite.call(stderr, string), getSize: () => process.stderr, temporarilyUnlock() { // no action needed }, close() { - // no action needed + 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 (stdout.end === newStdoutEnd) stdout.end = stdoutEnd; + if (stderr.end === newStderrEnd) stdout.end = stderrEnd; + for (const [key, old, patched] of restoreConsole) { + if (console[key] === patched) console[key] = old; + } }, }; }, now: () => performance.now(), delay: async.delay, + color: process.stderr.isTTY, }); + process.addListener("beforeExit", () => host.cancel()); + process.addListener("exit", () => host.cancel()); + return host; +} + +function defaultFallbackWidgetHost(): WidgetHost { + let warned = false; + return { + writeOutput: (line: string) => console.log(ansi.strip(line)), + writeError: (line: string) => console.error(ansi.strip(line)), + getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }), + startWidget: (options) => { + if (!warned) { + console.warn( + '"@clo/lib/log.ts"\'s startWidget was called in an environment ' + + "that does not support the Node.js 'process' API. Widgets " + + "will not be visible.", + ); + warned = true; + } + return { + options, + fps: options.fps ?? null, + redraw() {}, + stop() {}, + [Symbol.dispose]() {}, + }; + }, + cancel: () => {}, + capabilities: [], + }; +} + +/** returns a WidgetHost from `node:process`, but without patching its methods */ +export function simpleNodeProcessWidgetHost( + process: NonNullable, + forceWidgetSupport = false, +): WidgetHost { + return forceWidgetSupport || process.stderr.isTTY + ? createTerminalWidgetHost({ + lockTerminal: () => ({ + writeOutput: (string) => process.stdout.write(string), + writeInteractive: (string) => process.stderr.write(string), + getSize: () => process.stderr, + close() { + // no action needed + }, + }), + now: () => performance.now(), + delay: async.delay, + color: process.stderr.isTTY, + }) + : { + writeOutput: (string) => process.stdout.write(string), + writeError: (string) => process.stderr.write(string), + getDrawLock: () => ({ + release() {}, + [Symbol.dispose]() {}, + }), + startWidget: () => null, + cancel: () => {}, + capabilities: [], + }; } function bufferEndsInNewline(buffer: ArrayBufferView | undefined) { @@ -917,41 +1162,23 @@ function bufferEndsInNewline(buffer: ArrayBufferView | undefined) { : false; } -const levelToAnsi: Record = { +const levelToAnsi: Record = { info: `${ansi.fgBlue}info`, warn: `${ansi.fgYellow}warn`, error: `${ansi.fgRed}error`, debug: `${ansi.dim}dbg`, }; -let globalMessageFormatFunction: MessageFormatFunction = ( - { level, scope, text, newline }, - colors, -) => { - if (!text) return ""; - if (newline === false) return text; - const prefix = colors - // colorful - ? `${levelToAnsi[level]}${ - scope ? `(${scope})` : "" - }${ansi.fgReset}${ansi.dim}:${ansi.reset} ` - // colorless - : scope - ? `${level}(${scope}): ` - : `${level}: `; - return prefix + text + "\n"; -}; -let globalOutputFunction!: DispatchFunction; -const globalLog = /* @__PURE__ */ (() => { - const colors = node.process?.stderr.isTTY ?? false; - globalOutputFunction = node.process - // In Node.js, coordinate with the widget host - ? (message) => { - globalWidgetHost.write(globalMessageFormatFunction(message, colors)); - } - // Otherwise, forward to `console` - : (m) => { - let { level, [originalLogArgs]: args = [m.text], scope } = m; +const globalLog = /* @__PURE__ */ (() => + new ScopeImpl((m) => { + if (global.writeMessage) { + global.writeMessage(m); + } else if (node.process) { + globalWidgetHost()[ + (m.level ?? "info") === "info" ? "writeOutput" : "writeError" + ](formatAnsiMessage(m, node.process.stdout.isTTY)); + } else { + let { level = "info", [originalLogArgs]: args = [m.text], scope } = m; if (scope) { const arg0 = args[0]; const prefix = `[${scope}]`; @@ -959,9 +1186,9 @@ const globalLog = /* @__PURE__ */ (() => { else args.unshift(prefix); } console[level](...args); - }; - return new ScopeImpl(globalOutputFunction); -})(); + } + global.tees.forEach((cb) => cb(m)); + }))(); export type DispatchFunction = (message: Message) => void; /** diff --git a/lib/log/stack.ts b/lib/log/stack.ts index bc07f1e29f04cd82882c619007ae463888153ac4..dea85c8c17d7e8f7609e65e4f9dc0bb75f86de3d 100644 --- a/lib/log/stack.ts +++ b/lib/log/stack.ts @@ -274,7 +274,7 @@ function getPackageRoot(absPath: string) { ]; } return [ - `https://github.com/nodejs/node/blob/${process.version}/lib/`, + `https://github.com/nodejs/node/blob/${process.versions.node}/lib/`, "", absPath.slice(5) + ".js", ]; diff --git a/lib/node.ts b/lib/node.ts index 98101df52c6e8a90af123753649eaa8c08d37c19..5a4055e47f0accc465737241a75026be6219739b 100644 --- a/lib/node.ts +++ b/lib/node.ts @@ -1,8 +1,8 @@ /** * functions to load Node.js apis via `globalThis.process`. trivially bundlable * for the browser. does not depend on `@types/node` and does not intend to - * define types for the entire api. Instead, this is used for other library - * modules like `lib/log.ts` to bind to the system. + * define types for the entire api. instead, this file's types are used for + * other library modules like `lib/log.ts` to bind to the system. * * if you are using a competent bundler, you can define `globalThis.process` as * a bundling constant (esbuild: `--define`) to enable tree shaking across the @@ -10,22 +10,20 @@ * @module */ -export type ErrorCode = - | keyof typeof import("node:os").constants.errno - | (string & {}); - export const process: Process | undefined = - (globalThis as typeof globalThis & { process?: Process }).process ?? - undefined; + (globalThis as { process?: Process }).process && + (globalThis as { process?: Process }).process?.versions?.node + ? (globalThis as { process?: Process }).process + : undefined; export const isServer: boolean = !!process; /** partial types for Node.js `globalThis.process` */ -export interface Process { +interface Process { getBuiltinModule(name: K): Builtins[K] | null; - binding(name: K): Bindings[K] | null; + binding?(name: K): Bindings[K] | null; addListener(event: string, callback: () => void): this; - + versions: { node?: string; bun?: string; deno?: string }; stdout: Tty; stderr: Tty; } @@ -35,8 +33,8 @@ interface Tty { columns: number; rows: number; addListener(event: string, callback: () => void): this; - end(text?: string | Uint8Array): void; - write(text: string | Uint8Array): void; + end(text?: string | Uint8Array): this; + write(text: string | Uint8Array): boolean; } /** @@ -71,31 +69,39 @@ interface Builtins { }; }; } -/** - * Subset of Node.js binding types - */ + +/** subset of Node.js binding types */ interface Bindings { /** * key value mapping of internal module id to its source code. - * - * { "fs": "// Copyright Joyent, Inc. and other Node contributors.\n... + * ``` + * { "fs": "// Copyright Joyent, Inc. and other Node contributors.\n... + * ``` */ "natives": Partial>; } +/** return a built-in module */ export function builtin( name: K, ): Builtins[K] | undefined { return process?.getBuiltinModule(name) ?? undefined; } +/** return a built-in semi-private binding */ export function binding( name: K, ): Bindings[K] | undefined { if (!process) return undefined; try { - return process.binding(name) ?? undefined; + return process.binding?.(name) ?? undefined; } catch { return undefined; } } + +/** copy of `keyof typeof import('node:os').constants.errno` */ +// deno-fmt-ignore-next +export type ErrorCode = + | (string & {}) + | "E2BIG" | "EACCES" | "EADDRINUSE" | "EADDRNOTAVAIL" | "EAFNOSUPPORT" | "EAGAIN" | "EALREADY" | "EBADF" | "EBADMSG" | "EBUSY" | "ECANCELED" | "ECHILD" | "ECONNABORTED" | "ECONNREFUSED" | "ECONNRESET" | "EDEADLK" | "EDESTADDRREQ" | "EDOM" | "EDQUOT" | "EEXIST" | "EFAULT" | "EFBIG" | "EHOSTUNREACH" | "EIDRM" | "EILSEQ" | "EINPROGRESS" | "EINTR" | "EINVAL" | "EIO" | "EISCONN" | "EISDIR" | "ELOOP" | "EMFILE" | "EMLINK" | "EMSGSIZE" | "EMULTIHOP" | "ENAMETOOLONG" | "ENETDOWN" | "ENETRESET" | "ENETUNREACH" | "ENFILE" | "ENOBUFS" | "ENODATA" | "ENODEV" | "ENOENT" | "ENOEXEC" | "ENOLCK" | "ENOLINK" | "ENOMEM" | "ENOMSG" | "ENOPROTOOPT" | "ENOSPC" | "ENOSR" | "ENOSTR" | "ENOSYS" | "ENOTCONN" | "ENOTDIR" | "ENOTEMPTY" | "ENOTSOCK" | "ENOTSUP" | "ENOTTY" | "ENXIO" | "EOPNOTSUPP" | "EOVERFLOW" | "EPERM" | "EPIPE" | "EPROTO" | "EPROTONOSUPPORT" | "EPROTOTYPE" | "ERANGE" | "EROFS" | "ESPIPE" | "ESRCH" | "ESTALE" | "ETIME" | "ETIMEDOUT" | "ETXTBSY" | "EWOULDBLOCK" | "EXDEV" | "WSAEINTR" | "WSAEBADF" | "WSAEACCES" | "WSAEFAULT" | "WSAEINVAL" | "WSAEMFILE" | "WSAEWOULDBLOCK" | "WSAEINPROGRESS" | "WSAEALREADY" | "WSAENOTSOCK" | "WSAEDESTADDRREQ" | "WSAEMSGSIZE" | "WSAEPROTOTYPE" | "WSAENOPROTOOPT" | "WSAEPROTONOSUPPORT" | "WSAESOCKTNOSUPPORT" | "WSAEOPNOTSUPP" | "WSAEPFNOSUPPORT" | "WSAEAFNOSUPPORT" | "WSAEADDRINUSE" | "WSAEADDRNOTAVAIL" | "WSAENETDOWN" | "WSAENETUNREACH" | "WSAENETRESET" | "WSAECONNABORTED" | "WSAECONNRESET" | "WSAENOBUFS" | "WSAEISCONN" | "WSAENOTCONN" | "WSAESHUTDOWN" | "WSAETOOMANYREFS" | "WSAETIMEDOUT" | "WSAECONNREFUSED" | "WSAELOOP" | "WSAENAMETOOLONG" | "WSAEHOSTDOWN" | "WSAEHOSTUNREACH" | "WSAENOTEMPTY" | "WSAEPROCLIM" | "WSAEUSERS" | "WSAEDQUOT" | "WSAESTALE" | "WSAEREMOTE" | "WSASYSNOTREADY" | "WSAVERNOTSUPPORTED" | "WSANOTINITIALISED" | "WSAEDISCON" | "WSAENOMORE" | "WSAECANCELLED" | "WSAEINVALIDPROCTABLE" | "WSAEINVALIDPROVIDER" | "WSAEPROVIDERFAILEDINIT" | "WSASYSCALLFAILURE" | "WSASERVICE_NOT_FOUND" | "WSATYPE_NOT_FOUND" | "WSA_E_NO_MORE" | "WSA_E_CANCELLED" | "WSAEREFUSED"; diff --git a/lib/package.json b/lib/package.json new file mode 100644 index 0000000000000000000000000000000000000000..0f550cbaabd61f4c1d2c6ebe21ff458560e694b8 --- /dev/null +++ b/lib/package.json @@ -0,0 +1,4 @@ +{ + "type": "module", + "exports": { "./*": "./*.ts" } +} diff --git a/lib/progress.ts b/lib/progress.ts index 17a416158f7726880fa92421b12f3c4fa721b34d..f4a714f3a0e611fa067ec1549577813176bf24d4 100644 --- a/lib/progress.ts +++ b/lib/progress.ts @@ -37,7 +37,7 @@ * custom redirections. * * this module is under construction. while i am happy with the overall API, it - * needs more work and feature development. + * needs more work and feature development. the API of `Node` is stable, though. * * inspired by the [Zig Progress API](https://andrewkelley.me/post/zig-new-cli-progress-bar-explained.html). * @module @@ -74,7 +74,7 @@ export function start(text: string, opts?: StartOptions): Node { * * ```ts * await ffmpeg.spawn({ - * cmd: ["-i", "hello.mov", "-c:v", "@clo/libsvtav1", "hello.mp4"], + * cmd: ["-i", "hello.mov", "-c:v", "svtav1", "hello.mp4"], * progress: progress.start("encode hello.mov"), * }); * ``` @@ -705,13 +705,16 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { continue; } const logLines = child.logs - .map((msg) => log.formatMessage(msg, true)) + .map((msg) => log.formatAnsiMessage(msg, true)) .join("") .trim(); - if (logLines) for (const line of logLines.split("\n").slice(-Math.min(3, maxHeight))) { - item += left + (i === length - 1 && !truncated ? " " : box.line) + " " + - ansi.style(ansi.fgBrightBlack, ">") + - " " + line + "\n"; + if (logLines) { + for (const line of logLines.split("\n").slice(-Math.min(3, maxHeight))) { + item += left + (i === length - 1 && !truncated ? " " : box.line) + + " " + + ansi.style(ansi.fgBrightBlack, ">") + + " " + line + "\n"; + } } maxHeight -= h + logLines.length; out += item; @@ -765,30 +768,32 @@ export function formatUnicodeBar(progress: number, width: number): string { */ export function attachToScreen( root: Root, - { write, startWidget }: Pick< + { writeOutput, startWidget }: Pick< log.WidgetHost, - "write" | "startWidget" + "writeOutput" | "startWidget" >, ): ts.Dispose { const stack = new DisposableStack(); - - let rerender: (() => void) | null = null; - const widget: log.Widget = { - format: (now) => root.active ? formatAnsi(now, root.active) : null, - onChange: (cb) => (rerender = cb, () => rerender = null), - fps: spinnerFps, - }; + let widget: log.WidgetInstance | null = null; stack.use(root.on("change", (items) => { - if (rerender) rerender(); - else if (items.length > 0) startWidget(widget); + if (items.length > 0) { + widget ??= startWidget({ + format: ({ now }) => formatAnsi(now, root.active), + }) ?? null; + if (!widget)return; + widget.fps = items.some((x) => x.showTotal !== false && x.total > 0) + ? spinnerFps + : null; + widget.redraw(); + } else { + widget?.stop(); + widget = null; + } + })); + stack.use(root.on("node-detached-log", (msg) => { + writeOutput(log.formatAnsiMessage(msg, true)); })); - stack.use( - root.on( - "node-detached-log", - (msg) => write(log.formatMessage(msg, true)), - ), - ); stack.use(root.on("node-end", (node) => { let title = node.text; let p: ReadOnlyNode | null = node; @@ -796,8 +801,8 @@ export function attachToScreen( const { logs } = node; if (logs.length > 0) { const header = `[logs from ${title}]`; - write(ansi.style(ansi.fgBrightBlack, header) + "\n"); - write(logs.map((msg) => log.formatMessage(msg, true)).join("")); + writeOutput(ansi.style(ansi.fgBrightBlack, header) + "\n"); + writeOutput(logs.map((msg) => log.formatAnsiMessage(msg, true)).join("")); } })); @@ -878,8 +883,6 @@ export interface EncodeStreamOptions { * * if the given root adds custom event handlers, they must all have * json-serializable payloads. - * - * this function does not use recursion. */ export function encodeEventStream< Result extends ts.Json, @@ -1344,6 +1347,8 @@ interface DeltaFlags { } /** + * NOTE: this function contains bugs and its format is not yet stabilized. + * * converts a {@linkcode Root|progress.Root} into an byte stream for * communicating progress over the process or network boundary. the output is a * raw binary payload that uses an extremely compact representation for the @@ -1352,8 +1357,6 @@ interface DeltaFlags { * * if the given root adds custom event handlers, they must all have * json-serializable payloads. - * - * this function does not use recursion. */ export function encodeByteStream< Result extends ts.Json, @@ -1434,7 +1437,7 @@ function writeStreamEvent( if (messages !== undefined) { w.varUint(messages.length); for (const msg of messages) { - let level = logLevelSerialize.indexOf(msg.level); + let level = logLevelSerialize.indexOf(msg.level ?? "info"); if (level === -1) level = 0; w.u8( level + @@ -1743,6 +1746,12 @@ const globalKeyPool = new KeyPool(); const global: Ref = /** @__PURE__ */ ((root = new Root()) => (attachToScreen(root, log), root))(); +/** + * a {@linkcode Ref} to the global progress root. unlike referencing the + * namespace import, this value is tree-shakable. + */ +export const globalRoot: Ref = { start: global.start }; + /** * for testing. not covered by semver * @internal @@ -1755,7 +1764,7 @@ export const internals: { } = /** @__PURE__ */ (() => ({ readStreamEvent, writeStreamEvent, - kNode: kNode as unknown as symbol, + kNode: kNode as symbol, EncodedKey: 0 as EncodedKey, }))(); diff --git a/lib/readme.changes.md b/lib/readme.changes.md index 1f7958cea3e5893670833ee92c066e6bd060180d..1f169a95e4d4f41dc39500fc7d803a525effd27b 100644 --- a/lib/readme.changes.md +++ b/lib/readme.changes.md @@ -4,13 +4,17 @@ ### breaking +- the `.ts` suffix in the module name has been dropped - `log` - - rename `writeLine` to `write` + - rename `writeLine` to `writeOutput` and `writeError` + - `startWidget` returns `null` if the host is incapable of it + - it also no longer takes `onChange`. call `redraw` on the returned `WidgetInstance`. - rename `HeadlessWidgetHost` to `WidgetHost` - - rename `HeadlessWidgetEnv` to `WidgetHostOptions` - - rename `headlessWidgetHost` to `createWidgetHost` + - rename `headlessWidgetHost` to `createTerminalWidgetHost` + - rename `HeadlessWidgetEnv` to `TerminalWidgetHostOptions` - rename `replaceGlobalDestination` to `replaceGlobalMessageDestination` - - `WidgetHostOptions` takes a `lockTerminal` function instead of + - rename `formatMessage` to `formatAnsiMessage` + - `WidgetHostOptions` now takes a `lockTerminal` function instead of `writeInteractive`/`writeOutput` directly. the locking function returns an interface with these functions, which better aligns with how the draw lock actually works. additionally, this allows writing more accurate tty bindings @@ -20,23 +24,28 @@ - in `getDrawLock`, new required argument `mode`, which can be set to `short` or `long` to affect how synchronization works. releasing the lock requires you to give some information about where the cursor was moved to. - - messages now do not imply a newline -- `render` is now deprecated with no replacement. in the downstream `sitegen` - project, the codebase is moving to Marko after depending on both renderers. - `string/ansi` - rename `trimToWidth` to `trimForTerminal` +deprecations without removals: + +- `render.ts` will be deleted with no replacement. in the downstream `sitegen` + project, the codebase is moving to Marko after depending on both renderers. + ### features +- you can write `log` messages without a newline. on older libraries, this will + show up as a newline per message since that version was not capable of + displaying it. but done not as a breaking change. - `process.{stdout,stderr}.write` is intercepted to avoid log interweaving with `log.ts`/`progress.ts`. this is only done when a widget is created (for example, calling `progress.start`), so patches are not applied when not needed. if patching globals is undesirable, you can use an alternative widget host by calling `log.replaceGlobalWidgetHost(log.simpleNodeProcessWidgetHost(process))` - - consequences of this is that `getDrawLock` +- `log.startWidget` returns a `WidgetInstance`, including a mutable `fps` property. - `progress.ts` node gains `node.log.write("word ");` to write a message without a newline. -- `async.delay` handles timers longer than 23 days. +- `async.delay` handles timers longer than 23 days and `Infinity`. - `Lru.revive` recieves bug fixes. this function previously didn't really work. - `ansi` gets more cursor control constants diff --git a/lib/testing.ts b/lib/testing.ts index 7544c7ebdd9126f2a925a09529e3d03dd9591dce..26466c7f74e0c90a588ac5e6c9a932d873813013 100644 --- a/lib/testing.ts +++ b/lib/testing.ts @@ -166,9 +166,10 @@ export class MockScreen implements Disposable, log.WidgetHost { out: string = ""; writeCalls: WriteCall[] = []; - timers = new FakeTimers(); + timers: FakeTimers = new FakeTimers(); - write: log.WidgetHost["write"]; + writeOutput: log.WidgetHost["writeOutput"]; + writeError: log.WidgetHost["writeError"]; getDrawLock: log.WidgetHost["getDrawLock"]; startWidget: log.WidgetHost["startWidget"]; delay: log.WidgetHost["delay"]; @@ -182,7 +183,7 @@ export class MockScreen implements Disposable, log.WidgetHost { constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) { const callerFile = UNWRAP(stack.capture()[0]); - const host = log.createWidgetHost({ + const host = log.createTerminalWidgetHost({ lockTerminal: () => { ASSERT(!this.hasTerminalLock); this.hasTerminalLock = "locked"; @@ -190,24 +191,32 @@ export class MockScreen implements Disposable, log.WidgetHost { writeInteractive: (content) => { this.stderr += content; this.out += content; - const frames = stack.capture().filter(x => x.file !== import.meta.filename); - const cutoff = frames.findIndex(f => f.file === callerFile.file && f.fn === callerFile.fn) + const frames = stack.capture().filter((x) => + x.file !== import.meta.filename + ); + const cutoff = frames.findIndex((f) => + f.file === callerFile.file && f.fn === callerFile.fn + ); this.writeCalls.push({ - kind: "interactive", - stack: cutoff === -1 ? frames : frames.slice(0, cutoff), - content, - }) + kind: "interactive", + stack: cutoff === -1 ? frames : frames.slice(0, cutoff), + content, + }); }, writeOutput: (content) => { this.stdout += content; this.out += content; - const frames = stack.capture().filter(x => x.file !== import.meta.filename); - const cutoff = frames.findIndex(f => f.file === callerFile.file && f.fn === callerFile.fn) + const frames = stack.capture().filter((x) => + x.file !== import.meta.filename + ); + const cutoff = frames.findIndex((f) => + f.file === callerFile.file && f.fn === callerFile.fn + ); this.writeCalls.push({ - kind: "output", - stack: cutoff === -1 ? frames : frames.slice(0, cutoff), - content, - }) + kind: "output", + stack: cutoff === -1 ? frames : frames.slice(0, cutoff), + content, + }); }, getSize: () => { return this; @@ -229,8 +238,10 @@ export class MockScreen implements Disposable, log.WidgetHost { }, now: this.timers.now, delay: this.timers.delay, + color: true, }); - this.write = host.write; + this.writeOutput = host.writeOutput; + this.writeError = host.writeError; this.getDrawLock = host.getDrawLock; this.startWidget = host.startWidget; this.delay = host.delay; @@ -253,7 +264,8 @@ export class MockScreen implements Disposable, log.WidgetHost { if (ms != null) { const wait = UNWRAP( this.timers.entries.shift(), - () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o", + () => + this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o", ); ASSERT( ms === wait.duration, @@ -299,18 +311,22 @@ export class MockScreen implements Disposable, log.WidgetHost { this.writeCalls = []; } - fmtWriteCalls() { + fmtWriteCalls(): string { return `\n${ansi.reset}breakdown of calls that rendered this frame:\n` + - this.writeCalls.map((call, i) => - `${i + 1}. [${call.kind}] ${ansi.debugAnsi(call.content)}\n` - + call.stack.map(frame => ansi.reset + stack.formatFrame(frame, true)).join('\n') + + this.writeCalls.map((call, i) => + `${i + 1}. [${call.kind}] ${ansi.debugAnsi(call.content)}\n` + + call.stack.map((frame) => ansi.reset + stack.formatFrame(frame, true)) + .join("\n") + `\n` - ).join('\n').replaceAll(ansi.fgReset, ansi.reset) + ).join("\n").replaceAll(ansi.fgReset, ansi.reset); } [Symbol.dispose]() { this.cancel(); } + + capabilities = ["widget", "color"] as const; + cancel() { ASSERT(this.timers.entries.length === 0, "there is a pending write!"); ASSERT(!this.stdout, "unread standard out: " + this.stdout); -- 2.54.0