diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..1d4d634 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,12 @@ +{ + "name": "@clo/console", + "version": "0.1.0", + "exports": { + ".": "./src/console.ts", + "./Spinner": "./src/Spinner.ts", + "./Progress": "./src/Progress.ts", + "./Widget": "./src/Widget.ts", + "./inject": "./src/inject.ts", + "./error": "./src/error.ts" + } +} diff --git a/examples/basic.ts b/examples/basic.ts index e0501b0..02380ea 100644 --- a/examples/basic.ts +++ b/examples/basic.ts @@ -1,10 +1,10 @@ -import * as console from '@paperclover/console'; +import * as console from "@paperclover/console"; -console.info('Hello, world!'); -console.warn('This is a warning'); -console.error('This is an error'); -console.debug('This is a debug message'); -console.success('This is a success message'); +console.info("Hello, world!"); +console.warn("This is a warning"); +console.error("This is an error"); +console.debug("This is a debug message"); +console.success("This is a success message"); -const custom = console.scoped('my_own'); -custom('Hello, world!'); \ No newline at end of file +const custom = console.scoped("my_own"); +custom("Hello, world!"); diff --git a/examples/inject.ts b/examples/inject.ts index 65111ab..cb82872 100644 --- a/examples/inject.ts +++ b/examples/inject.ts @@ -1,6 +1,6 @@ -import '@paperclover/console/inject'; +import "@paperclover/console/inject"; -console.info('Hello, world!'); -console.warn('This is a warning'); -console.error('This is an error'); -console.debug('This is a debug message'); +console.info("Hello, world!"); +console.warn("This is a warning"); +console.error("This is an error"); +console.debug("This is a debug message"); diff --git a/examples/progress-bar.ts b/examples/progress-bar.ts index 85dbd6d..451ac8a 100644 --- a/examples/progress-bar.ts +++ b/examples/progress-bar.ts @@ -1,11 +1,11 @@ import { withProgress } from "@paperclover/console/Progress"; -await withProgress('do a task', async (progress) => { - // mutate the progress object - progress.total = 100; +await withProgress("do a task", async (progress) => { + // mutate the progress object + progress.total = 100; - for (let i = 0; i < progress.total; i++) { - await new Promise(resolve => setTimeout(resolve, 10)); - progress.value += 1; - } + for (let i = 0; i < progress.total; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + progress.value += 1; + } }); diff --git a/examples/spinner.ts b/examples/spinner.ts index 93a6c93..2cc7210 100644 --- a/examples/spinner.ts +++ b/examples/spinner.ts @@ -1,34 +1,34 @@ import { Spinner } from "@paperclover/console/Spinner"; const first = new Spinner({ - text: 'Spinner 1: ', - color: 'blueBright', + text: "Spinner 1: ", + color: "blueBright", }); const second = new Spinner({ - text: () => `Spinner 2: ${random()}`, - color: 'blueBright', + text: () => `Spinner 2: ${random()}`, + color: "blueBright", }); second.fps = 30; const third = new Spinner<{ value: string }>({ - text: ({ value }) => `Spinner 3: ${value}`, - color: 'blueBright', + text: ({ value }) => `Spinner 3: ${value}`, + color: "blueBright", }); third.fps = 4; for (let i = 0; i < 40; i++) { - await new Promise(resolve => setTimeout(resolve, 100)); - first.text = `Spinner 1: ${random()}`; - if (i === 20) { - second.success('second done!'); - } - third.update({ value: random() }); + await new Promise((resolve) => setTimeout(resolve, 100)); + first.text = `Spinner 1: ${random()}`; + if (i === 20) { + second.success("second done!"); + } + third.update({ value: random() }); } -first.success('first done!'); +first.success("first done!"); // third.success('third done!'); function random() { - return Math.random().toString(36).substring(2, 15); -} \ No newline at end of file + return Math.random().toString(36).substring(2, 15); +} diff --git a/src/Progress.test.ts b/src/Progress.test.ts index 12dc00b..046ee51 100644 --- a/src/Progress.test.ts +++ b/src/Progress.test.ts @@ -1,336 +1,349 @@ -import { beforeEach, expect, test, mock } from "bun:test"; -import { reset, expectCalls, getWidgets } from "./test/widget-mock.ts"; +import { beforeEach, expect, mock, test } from "bun:test"; +import { expectCalls, getWidgets, reset } from "./test/widget-mock.ts"; -const { Progress, withProgress } = await import("@paperclover/console/Progress"); -type Progress = Record> = import("@paperclover/console/Progress").Progress; +const { Progress, withProgress } = await import( + "@paperclover/console/Progress" +); +type Progress = Record> = + import("@paperclover/console/Progress").Progress; beforeEach(reset); test("default options and basic rendering", () => { - const progress = new Progress("Loading"); + const progress = new Progress("Loading"); - expect(progress.text).toEqual("Loading"); - expect(progress.beforeText).toEqual(""); - expect(progress.value).toEqual(0); - expect(progress.total).toEqual(100); + expect(progress.text).toEqual("Loading"); + expect(progress.beforeText).toEqual(""); + expect(progress.value).toEqual(0); + expect(progress.total).toEqual(100); - // Format should include a progress bar - const formatted = progress.format(0); - expect(formatted).toContain("Loading"); - // The progress bar might not contain "█" at 0% progress, - // but it should contain color codes and spaces for the bar - expect(formatted).toContain("\u001b[38;2;"); // Color code + // Format should include a progress bar + const formatted = progress.format(0); + expect(formatted).toContain("Loading"); + // The progress bar might not contain "█" at 0% progress, + // but it should contain color codes and spaces for the bar + expect(formatted).toContain("\u001b[38;2;"); // Color code }); test("progress percentage displays correctly", () => { - const progress = new Progress("Loading"); + const progress = new Progress("Loading"); - // Test zero progress - progress.value = 0; - expect(progress.format(0)).toContain("Loading"); + // Test zero progress + progress.value = 0; + expect(progress.format(0)).toContain("Loading"); - // Test partial progress - progress.value = 50; - const halfProgress = progress.format(0); - expect(halfProgress).toContain("Loading"); + // Test partial progress + progress.value = 50; + const halfProgress = progress.format(0); + expect(halfProgress).toContain("Loading"); - // Test full progress - progress.value = 100; - const fullProgress = progress.format(0); - expect(fullProgress).toContain("Loading"); + // Test full progress + progress.value = 100; + const fullProgress = progress.format(0); + expect(fullProgress).toContain("Loading"); - // Should be visibly different - expect(progress.format(0)).not.toEqual(halfProgress); + // Should be visibly different + expect(progress.format(0)).not.toEqual(halfProgress); }); test("custom options apply correctly", () => { - const progress = new Progress({ - text: "Custom Progress", - beforeText: "Loading:", - barWidth: 20, - barStyle: "ascii", - value: 25, - total: 200 - }); + const progress = new Progress({ + text: "Custom Progress", + beforeText: "Loading:", + barWidth: 20, + barStyle: "ascii", + value: 25, + total: 200, + }); - expect(progress.text).toEqual("Custom Progress"); - expect(progress.beforeText).toEqual("Loading:"); - expect(progress.value).toEqual(25); - expect(progress.total).toEqual(200); + expect(progress.text).toEqual("Custom Progress"); + expect(progress.beforeText).toEqual("Loading:"); + expect(progress.value).toEqual(25); + expect(progress.total).toEqual(200); - const formatted = progress.format(0); - expect(formatted).toContain("Custom Progress"); - expect(formatted).toContain("Loading:"); - // With ascii style, it should contain these characters - expect(formatted).toContain("["); - expect(formatted).toContain("]"); + const formatted = progress.format(0); + expect(formatted).toContain("Custom Progress"); + expect(formatted).toContain("Loading:"); + // With ascii style, it should contain these characters + expect(formatted).toContain("["); + expect(formatted).toContain("]"); }); test("spinner options", () => { - // Test with custom spinner - const customSpinnerProgress = new Progress({ - text: "With spinner", - spinner: { - frames: ["A", "B", "C"], - fps: 10, - color: "red" - } - }); + // Test with custom spinner + const customSpinnerProgress = new Progress({ + text: "With spinner", + spinner: { + frames: ["A", "B", "C"], + fps: 10, + color: "red", + }, + }); - expect(customSpinnerProgress.fps).toBeGreaterThan(0); // Should have fps when spinner is enabled - const formatted = customSpinnerProgress.format(0); - expect(formatted).toContain("A"); // Should include the first spinner frame + expect(customSpinnerProgress.fps).toBeGreaterThan(0); // Should have fps when spinner is enabled + const formatted = customSpinnerProgress.format(0); + expect(formatted).toContain("A"); // Should include the first spinner frame - // Test with spinner disabled - const noSpinnerProgress = new Progress({ - text: "No spinner", - spinner: null - }); + // Test with spinner disabled + const noSpinnerProgress = new Progress({ + text: "No spinner", + spinner: null, + }); - expect(noSpinnerProgress.fps).toBe(0); // Should have 0 fps when spinner is disabled - const noSpinnerFormatted = noSpinnerProgress.format(0); - expect(noSpinnerFormatted).not.toContain("A"); - expect(noSpinnerFormatted).not.toContain("B"); - expect(noSpinnerFormatted).not.toContain("C"); + expect(noSpinnerProgress.fps).toBe(0); // Should have 0 fps when spinner is disabled + const noSpinnerFormatted = noSpinnerProgress.format(0); + expect(noSpinnerFormatted).not.toContain("A"); + expect(noSpinnerFormatted).not.toContain("B"); + expect(noSpinnerFormatted).not.toContain("C"); }); test("text getter and setter", () => { - const progress = new Progress("Initial text"); - expect(progress.text).toEqual("Initial text"); + const progress = new Progress("Initial text"); + expect(progress.text).toEqual("Initial text"); - progress.text = "Updated text"; - expect(progress.text).toEqual("Updated text"); + progress.text = "Updated text"; + expect(progress.text).toEqual("Updated text"); - expectCalls(progress, { - redraws: 1 - }); + expectCalls(progress, { + redraws: 1, + }); }); test("beforeText getter and setter", () => { - const progress = new Progress({ - text: "Main text", - beforeText: "Initial before" - }); - expect(progress.beforeText).toEqual("Initial before"); + const progress = new Progress({ + text: "Main text", + beforeText: "Initial before", + }); + expect(progress.beforeText).toEqual("Initial before"); - progress.beforeText = "Updated before"; - expect(progress.beforeText).toEqual("Updated before"); + progress.beforeText = "Updated before"; + expect(progress.beforeText).toEqual("Updated before"); - expectCalls(progress, { - redraws: 1 - }); + expectCalls(progress, { + redraws: 1, + }); }); test("value and total getters and setters", () => { - const progress = new Progress("Progress"); - expect(progress.value).toEqual(0); - expect(progress.total).toEqual(100); + const progress = new Progress("Progress"); + expect(progress.value).toEqual(0); + expect(progress.total).toEqual(100); - progress.value = 25; - expect(progress.value).toEqual(25); - expectCalls(progress, { - redraws: 1 - }); + progress.value = 25; + expect(progress.value).toEqual(25); + expectCalls(progress, { + redraws: 1, + }); - progress.total = 200; - expect(progress.total).toEqual(200); - expectCalls(progress, { - redraws: 2 - }); + progress.total = 200; + expect(progress.total).toEqual(200); + expectCalls(progress, { + redraws: 2, + }); }); test("function for text field and props", () => { - const textFn = (props: { value: number; total: number; progress: number; customProp: string }) => - `${props.customProp}: ${props.value}/${props.total} (${Math.round(props.progress * 100)}%)`; + const textFn = ( + props: { + value: number; + total: number; + progress: number; + customProp: string; + }, + ) => + `${props.customProp}: ${props.value}/${props.total} (${ + Math.round(props.progress * 100) + }%)`; - const progress = new Progress<{ customProp: string }>({ - text: textFn, - props: { customProp: "Loading" }, - value: 30, - total: 120 - }); + const progress = new Progress<{ customProp: string }>({ + text: textFn, + props: { customProp: "Loading" }, + value: 30, + total: 120, + }); - // Text function should use props and calculate progress - expect(progress.text).toEqual("Loading: 30/120 (25%)"); + // Text function should use props and calculate progress + expect(progress.text).toEqual("Loading: 30/120 (25%)"); - // Updating props should reflect in text - progress.props = { customProp: "Downloading" }; - expect(progress.text).toEqual("Downloading: 30/120 (25%)"); + // Updating props should reflect in text + progress.props = { customProp: "Downloading" }; + expect(progress.text).toEqual("Downloading: 30/120 (25%)"); - // Updating value should reflect in text - progress.value = 60; - expect(progress.text).toEqual("Downloading: 60/120 (50%)"); + // Updating value should reflect in text + progress.value = 60; + expect(progress.text).toEqual("Downloading: 60/120 (50%)"); - expectCalls(progress, { - redraws: 2 - }); + expectCalls(progress, { + redraws: 2, + }); }); test("update method and its overloads", () => { - const progress = new Progress<{ status: string }>({ - text: (props) => `${props.status}: ${props.value}/${props.total}`, - props: { status: "Loading" } - }); + const progress = new Progress<{ status: string }>({ + text: (props) => `${props.status}: ${props.value}/${props.total}`, + props: { status: "Loading" }, + }); - // Initial state - expect(progress.text).toEqual("Loading: 0/100"); + // Initial state + expect(progress.text).toEqual("Loading: 0/100"); - // Update with just value - progress.update(50); - expect(progress.value).toEqual(50); - expect(progress.text).toEqual("Loading: 50/100"); + // Update with just value + progress.update(50); + expect(progress.value).toEqual(50); + expect(progress.text).toEqual("Loading: 50/100"); - // Update with value and props - progress.update(75, { status: "Downloading" }); - expect(progress.value).toEqual(75); - expect(progress.text).toEqual("Downloading: 75/100"); + // Update with value and props + progress.update(75, { status: "Downloading" }); + expect(progress.value).toEqual(75); + expect(progress.text).toEqual("Downloading: 75/100"); - expectCalls(progress, { - redraws: 2 - }); + expectCalls(progress, { + redraws: 2, + }); }); test("success and error methods", () => { - const progress = new Progress("Working"); + const progress = new Progress("Working"); - // Test success with default message - progress.success(); - expectCalls(progress, { - successCalls: ["Working"] - }); + // Test success with default message + progress.success(); + expectCalls(progress, { + successCalls: ["Working"], + }); - // Test success with custom message - const progress2 = new Progress("Working"); - progress2.success("Completed"); - expectCalls(progress2, { - successCalls: ["Completed"] - }); + // Test success with custom message + const progress2 = new Progress("Working"); + progress2.success("Completed"); + expectCalls(progress2, { + successCalls: ["Completed"], + }); - // Test error with default message - const progress3 = new Progress("Working"); - progress3.error(); - expectCalls(progress3, { - errorCalls: ["Working"] - }); + // Test error with default message + const progress3 = new Progress("Working"); + progress3.error(); + expectCalls(progress3, { + errorCalls: ["Working"], + }); - // Test error with custom message - const progress4 = new Progress("Working"); - progress4.error("Failed"); - expectCalls(progress4, { - errorCalls: ["Failed"] - }); + // Test error with custom message + const progress4 = new Progress("Working"); + progress4.error("Failed"); + expectCalls(progress4, { + errorCalls: ["Failed"], + }); - // Test error with Error object - const error = new Error("Something went wrong"); - const progress5 = new Progress("Working"); - progress5.error(error); - expectCalls(progress5, { - errorCalls: [error] - }); + // Test error with Error object + const error = new Error("Something went wrong"); + const progress5 = new Progress("Working"); + progress5.error(error); + expectCalls(progress5, { + errorCalls: [error], + }); }); test("withProgress function", async () => { - const expectedResult = Symbol("result"); - let bar!: Progress; + const expectedResult = Symbol("result"); + let bar!: Progress; - try { - // We need to use try/finally to ensure we can access bar for validation - await withProgress("Processing", async (progress) => { - bar = progress; - expect(progress.text).toEqual("Processing"); + try { + // We need to use try/finally to ensure we can access bar for validation + await withProgress("Processing", async (progress) => { + bar = progress; + expect(progress.text).toEqual("Processing"); - // Update progress during operation - progress.value = 50; + // Update progress during operation + progress.value = 50; - return expectedResult; - }); - } finally { - expectCalls(bar, { - successCalls: ["Completed"], - redraws: 1 - }); - } + return expectedResult; + }); + } finally { + expectCalls(bar, { + successCalls: ["Completed"], + redraws: 1, + }); + } }); test("withProgress function with custom success text", async () => { - let bar!: Progress; + let bar!: Progress; - try { - await withProgress({ - text: "Processing", - successText: "Task completed successfully" - }, async (progress) => { - bar = progress; - return "data"; - }); - } finally { - expectCalls(bar, { - successCalls: ["Task completed successfully"], - redraws: 0 - }); - } + try { + await withProgress({ + text: "Processing", + successText: "Task completed successfully", + }, async (progress) => { + bar = progress; + return "data"; + }); + } finally { + expectCalls(bar, { + successCalls: ["Task completed successfully"], + redraws: 0, + }); + } }); test("withProgress function with success text function", async () => { - let bar!: Progress; - const data = { count: 42 }; + let bar!: Progress; + const data = { count: 42 }; - try { - await withProgress({ - text: "Processing", - successText: (result: { count: number }) => `Processed ${result.count} items` - }, async (progress) => { - bar = progress; - return data; - }); - } finally { - expectCalls(bar, { - successCalls: ["Processed 42 items"], - redraws: 0 - }); - } + try { + await withProgress({ + text: "Processing", + successText: (result: { count: number }) => + `Processed ${result.count} items`, + }, async (progress) => { + bar = progress; + return data; + }); + } finally { + expectCalls(bar, { + successCalls: ["Processed 42 items"], + redraws: 0, + }); + } }); test("withProgress function with error", async () => { - const error = new Error("Process failed"); - let bar!: Progress; + const error = new Error("Process failed"); + let bar!: Progress; - try { - await withProgress({ - text: "Processing", - failureText: "Task failed" - }, async (progress) => { - bar = progress; - throw error; - }); - expect.unreachable(); - } catch (e) { - expect(e).toEqual(error); - expectCalls(bar, { - errorCalls: ["Task failed"], - redraws: 0 - }); - } + try { + await withProgress({ + text: "Processing", + failureText: "Task failed", + }, async (progress) => { + bar = progress; + throw error; + }); + expect.unreachable(); + } catch (e) { + expect(e).toEqual(error); + expectCalls(bar, { + errorCalls: ["Task failed"], + redraws: 0, + }); + } }); test("withProgress function with error text function", async () => { - const error = new Error("Process failed with code 500"); - let bar!: Progress; + const error = new Error("Process failed with code 500"); + let bar!: Progress; - try { - await withProgress({ - text: "Processing", - failureText: (err) => `Error: ${err.message}` - }, async (progress) => { - bar = progress; - throw error; - }); - expect.unreachable(); - } catch (e) { - expect(e).toEqual(error); - expectCalls(bar, { - errorCalls: ["Error: Process failed with code 500"], - redraws: 0 - }); - } -}); \ No newline at end of file + try { + await withProgress({ + text: "Processing", + failureText: (err) => `Error: ${err.message}`, + }, async (progress) => { + bar = progress; + throw error; + }); + expect.unreachable(); + } catch (e) { + expect(e).toEqual(error); + expectCalls(bar, { + errorCalls: ["Error: Process failed with code 500"], + redraws: 0, + }); + } +}); diff --git a/src/Progress.ts b/src/Progress.ts index e37ca8b..36e8da4 100644 --- a/src/Progress.ts +++ b/src/Progress.ts @@ -1,16 +1,20 @@ -import chalk from 'chalk'; -import { convertHSVtoRGB, getColor, type CustomLoggerColor } from './internal.ts'; -import { defaultSpinnerOptions } from './Spinner.ts'; -import { isUnicodeSupported } from './console.ts'; -import { Widget } from './Widget.ts'; +import chalk from "chalk"; +import { + convertHSVtoRGB, + type CustomLoggerColor, + getColor, +} from "./internal.ts"; +import { defaultSpinnerOptions } from "./Spinner.ts"; +import { isUnicodeSupported } from "./console.ts"; +import { Widget } from "./Widget.ts"; -const boxChars = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉']; -const fullBox = '█'; +const boxChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; +const fullBox = "█"; const asciiChars = { - start: '[', - end: ']', - empty: ' ', - fill: '=', + start: "[", + end: "]", + empty: " ", + fill: "=", }; /** @@ -26,7 +30,7 @@ function getUnicodeBar(progress: number, width: number) { return fullBox.repeat(width); } if (progress <= 0 || isNaN(progress)) { - return ' '.repeat(width); + return " ".repeat(width); } const wholeWidth = Math.floor(progress * width); @@ -34,11 +38,11 @@ function getUnicodeBar(progress: number, width: number) { const partWidth = Math.floor(remainderWidth * 8); let partChar = boxChars[partWidth]; if (width - wholeWidth - 1 < 0) { - partChar = ''; + partChar = ""; } const fill = fullBox.repeat(wholeWidth); - const empty = ' '.repeat(width - wholeWidth - 1); + const empty = " ".repeat(width - wholeWidth - 1); return `${fill}${partChar}${empty}`; } @@ -50,14 +54,16 @@ function getAsciiBar(progress: number, width: number) { asciiChars.fill.repeat(Math.floor(progress * (width - 2))), asciiChars.empty.repeat(width - Math.ceil(progress * (width - 2))), asciiChars.end, - ].join(''); + ].join(""); } /** A Progress Bar Style. Ascii is forced in non-unicode terminals. */ -export type BarStyle = 'unicode' | 'ascii'; +export type BarStyle = "unicode" | "ascii"; /** Options to be passed to `new Progress` */ -export interface ProgressOptions = Record> { +export interface ProgressOptions< + Props extends Record = Record, +> { /** Text displayed to the right of the bar. */ text: string | ((props: ExtendedProps) => string); /** Text displayed to the left of the bar, if specified. */ @@ -82,17 +88,17 @@ export interface BarSpinnerOptions { /** Sequence of frames for the spinner. */ frames: string[]; /** Color of the spinner. If set to `match` it will match the bar. */ - color: CustomLoggerColor | 'match'; + color: CustomLoggerColor | "match"; } const defaultOptions = { - beforeText: '', + beforeText: "", barWidth: 35, - barColor: 'rgb', - barStyle: 'unicode', + barColor: "rgb", + barStyle: "unicode", spinner: { ...defaultSpinnerOptions, - color: 'match', + color: "match", }, value: 0, total: 100, @@ -105,12 +111,14 @@ type ExtendedProps = T & { progress: number; }; -export class Progress = Record> extends Widget { +export class Progress< + Props extends Record = Record, +> extends Widget { #text: string | ((props: ExtendedProps) => string); #beforeText: string | ((props: ExtendedProps) => string); #barWidth: number; - #barStyle: NonNullable; - #spinnerColor: NonNullable; + #barStyle: NonNullable; + #spinnerColor: NonNullable; #spinnerFrames?: readonly string[]; #props: Props; #spinnerFPS: number; @@ -121,7 +129,7 @@ export class Progress = Record | string) { super(); - if (typeof options === 'string') { + if (typeof options === "string") { options = { text: options }; } @@ -138,8 +146,10 @@ export class Progress = Record = Record string)) { @@ -178,7 +190,9 @@ export class Progress = Record string)) { @@ -186,7 +200,7 @@ export class Progress = Record = Record = Record, T> /** Calls a function with a progress bar. */ export async function withProgress, T>( opts: WithProgressOptions | string, - fn: (bar: Progress) => Promise + fn: (bar: Progress) => Promise, ): Promise; export async function withProgress(opts: any, fn: any) { const bar = new Progress(opts); @@ -279,18 +296,18 @@ export async function withProgress(opts: any, fn: any) { const result = await fn(bar); bar.success( opts.successText - ? typeof opts.successText === 'function' + ? typeof opts.successText === "function" ? opts.successText(result) : opts.successText : opts.text - ? typeof opts.text === 'function' - ? opts.text(bar.props) - : opts.text - : 'Completed' + ? typeof opts.text === "function" ? opts.text(bar.props) : opts.text + : "Completed", ); } catch (error: any) { bar.error( - typeof opts.failureText === 'function' ? opts.failureText(error) : opts.failureText ?? error + typeof opts.failureText === "function" + ? opts.failureText(error) + : (opts.failureText ?? error), ); throw error; } diff --git a/src/Spinner.test.ts b/src/Spinner.test.ts index 85f4ccc..b9b5f1c 100644 --- a/src/Spinner.test.ts +++ b/src/Spinner.test.ts @@ -1,288 +1,299 @@ import { beforeEach, expect, test } from "bun:test"; -import { reset, expectCalls, getWidgets } from "./test/widget-mock.ts"; +import { expectCalls, getWidgets, reset } from "./test/widget-mock.ts"; -const { Spinner, defaultSpinnerOptions, withSpinner } = await import("@paperclover/console/Spinner"); -type Spinner = Record> = import("@paperclover/console/Spinner").Spinner; +const { Spinner, defaultSpinnerOptions, withSpinner } = await import( + "@paperclover/console/Spinner" +); +type Spinner = Record> = + import("@paperclover/console/Spinner").Spinner; beforeEach(reset); test("default options and rendering", () => { - const spinner = new Spinner("spin"); - expect(spinner.props).toEqual({}); - expect(spinner.fps).toEqual(defaultSpinnerOptions.fps); - expect(spinner.frames).toEqual(defaultSpinnerOptions.frames); - expect(defaultSpinnerOptions.frames).toHaveLength(10); + const spinner = new Spinner("spin"); + expect(spinner.props).toEqual({}); + expect(spinner.fps).toEqual(defaultSpinnerOptions.fps); + expect(spinner.frames).toEqual(defaultSpinnerOptions.frames); + expect(defaultSpinnerOptions.frames).toHaveLength(10); - let now = 0; - for (let i = 0; i < spinner.frames.length; i++) { - expect(spinner.format(now)).toEqual(`\x1b[94m${spinner.frames[i]}\x1b[39m spin`); - now += 1000 / spinner.fps; - } + let now = 0; + for (let i = 0; i < spinner.frames.length; i++) { + expect(spinner.format(now)).toEqual( + `\x1b[94m${spinner.frames[i]}\x1b[39m spin`, + ); + now += 1000 / spinner.fps; + } - spinner.text = "something"; + spinner.text = "something"; - expectCalls(spinner, { - redraws: 1, - }); + expectCalls(spinner, { + redraws: 1, + }); }); test("custom options apply correctly", () => { - const customFrames = ['A', 'B', 'C']; - const customFps = 24; + const customFrames = ["A", "B", "C"]; + const customFps = 24; - const spinner = new Spinner({ - text: "custom spinner", - frames: customFrames, - fps: customFps - }); + const spinner = new Spinner({ + text: "custom spinner", + frames: customFrames, + fps: customFps, + }); - expect(spinner.frames).toEqual(customFrames); - expect(spinner.fps).toEqual(customFps); - expect(spinner.text).toEqual("custom spinner"); + expect(spinner.frames).toEqual(customFrames); + expect(spinner.fps).toEqual(customFps); + expect(spinner.text).toEqual("custom spinner"); - let now = 0; - for (let i = 0; i < spinner.frames.length; i++) { - expect(spinner.format(now)).toEqual(`\x1b[94m${spinner.frames[i]}\x1b[39m custom spinner`); - now += 1000 / spinner.fps; - } + let now = 0; + for (let i = 0; i < spinner.frames.length; i++) { + expect(spinner.format(now)).toEqual( + `\x1b[94m${spinner.frames[i]}\x1b[39m custom spinner`, + ); + now += 1000 / spinner.fps; + } }); test("custom color options", () => { - // Test with named color - const redSpinner = new Spinner({ - text: "red spinner", - color: "red" - }); - expect(redSpinner.format(0)).toEqual(`\x1b[31m${redSpinner.frames[0]}\x1b[39m red spinner`); + // Test with named color + const redSpinner = new Spinner({ + text: "red spinner", + color: "red", + }); + expect(redSpinner.format(0)).toEqual( + `\x1b[31m${redSpinner.frames[0]}\x1b[39m red spinner`, + ); - // Test with hex color - const hexSpinner = new Spinner({ - text: "hex spinner", - color: "#ff00ff" - }); - expect(hexSpinner.format(0)).toContain(hexSpinner.frames[0]); - expect(hexSpinner.format(0)).toContain("hex spinner"); + // Test with hex color + const hexSpinner = new Spinner({ + text: "hex spinner", + color: "#ff00ff", + }); + expect(hexSpinner.format(0)).toContain(hexSpinner.frames[0]); + expect(hexSpinner.format(0)).toContain("hex spinner"); - // Test with rgb array - const rgbSpinner = new Spinner({ - text: "rgb spinner", - color: [255, 0, 255] - }); - expect(rgbSpinner.format(0)).toContain(rgbSpinner.frames[0]); - expect(rgbSpinner.format(0)).toContain("rgb spinner"); + // Test with rgb array + const rgbSpinner = new Spinner({ + text: "rgb spinner", + color: [255, 0, 255], + }); + expect(rgbSpinner.format(0)).toContain(rgbSpinner.frames[0]); + expect(rgbSpinner.format(0)).toContain("rgb spinner"); - // Test with color disabled - const noColorSpinner = new Spinner({ - text: "no color", - color: false - }); - expect(noColorSpinner.format(0)).toEqual(`${noColorSpinner.frames[0]} no color`); + // Test with color disabled + const noColorSpinner = new Spinner({ + text: "no color", + color: false, + }); + expect(noColorSpinner.format(0)).toEqual( + `${noColorSpinner.frames[0]} no color`, + ); }); test("invalid color throws error", () => { - expect(() => { - new Spinner({ - text: "invalid color", - color: "invalidColor" as any - }); - }).toThrow("Invalid color: invalidColor"); + expect(() => { + new Spinner({ + text: "invalid color", + color: "invalidColor" as any, + }); + }).toThrow("Invalid color: invalidColor"); }); test("text getter and setter", () => { - const spinner = new Spinner("initial text"); - expect(spinner.text).toEqual("initial text"); + const spinner = new Spinner("initial text"); + expect(spinner.text).toEqual("initial text"); - spinner.text = "updated text"; - expect(spinner.text).toEqual("updated text"); + spinner.text = "updated text"; + expect(spinner.text).toEqual("updated text"); - expectCalls(spinner, { - redraws: 1 - }); + expectCalls(spinner, { + redraws: 1, + }); }); test("function for text field and props getter and setter", () => { - const textFn = (props: { count: number }) => `Items: ${props.count}`; - const spinner = new Spinner<{ count: number; newProp?: string }>({ - text: textFn, - props: { count: 5 } - }); + const textFn = (props: { count: number }) => `Items: ${props.count}`; + const spinner = new Spinner<{ count: number; newProp?: string }>({ + text: textFn, + props: { count: 5 }, + }); - expect(spinner.text).toEqual("Items: 5"); - expect(spinner.props).toEqual({ count: 5 }); + expect(spinner.text).toEqual("Items: 5"); + expect(spinner.props).toEqual({ count: 5 }); - spinner.props = { count: 10 }; - expect(spinner.text).toEqual("Items: 10"); - expect(spinner.props).toEqual({ count: 10 }); + spinner.props = { count: 10 }; + expect(spinner.text).toEqual("Items: 10"); + expect(spinner.props).toEqual({ count: 10 }); - expectCalls(spinner, { - redraws: 1 - }); + expectCalls(spinner, { + redraws: 1, + }); - // Adding new props should keep existing ones - spinner.props = { newProp: "value" }; - expect(spinner.props).toEqual({ count: 10, newProp: "value" }); + // Adding new props should keep existing ones + spinner.props = { newProp: "value" }; + expect(spinner.props).toEqual({ count: 10, newProp: "value" }); - expectCalls(spinner, { - redraws: 2 - }); + expectCalls(spinner, { + redraws: 2, + }); }); test("update method with string", () => { - const spinner = new Spinner("initial"); + const spinner = new Spinner("initial"); - spinner.update("updated via update"); - expect(spinner.text).toEqual("updated via update"); + spinner.update("updated via update"); + expect(spinner.text).toEqual("updated via update"); - expectCalls(spinner, { - redraws: 1 - }); + expectCalls(spinner, { + redraws: 1, + }); }); test("update method with props", () => { - const textFn = (props: { count: number, name?: string }) => - `${props.name || 'Items'}: ${props.count}`; + const textFn = (props: { count: number; name?: string }) => + `${props.name || "Items"}: ${props.count}`; - const spinner = new Spinner<{ count: number, name?: string }>({ - text: textFn, - props: { count: 0 } - }); + const spinner = new Spinner<{ count: number; name?: string }>({ + text: textFn, + props: { count: 0 }, + }); - expect(spinner.text).toEqual("Items: 0"); + expect(spinner.text).toEqual("Items: 0"); - spinner.update({ count: 5 }); - expect(spinner.text).toEqual("Items: 5"); + spinner.update({ count: 5 }); + expect(spinner.text).toEqual("Items: 5"); - spinner.update({ name: "Products" }); - expect(spinner.text).toEqual("Products: 5"); + spinner.update({ name: "Products" }); + expect(spinner.text).toEqual("Products: 5"); - expectCalls(spinner, { - redraws: 2 - }); + expectCalls(spinner, { + redraws: 2, + }); }); test("success method", () => { - const spinner = new Spinner("working"); + const spinner = new Spinner("working"); - spinner.success(); - expectCalls(spinner, { - successCalls: ["working"] - }); + spinner.success(); + expectCalls(spinner, { + successCalls: ["working"], + }); - const spinner2 = new Spinner("working"); - spinner2.success("completed"); - expectCalls(spinner2, { - successCalls: ["completed"] - }); + const spinner2 = new Spinner("working"); + spinner2.success("completed"); + expectCalls(spinner2, { + successCalls: ["completed"], + }); }); test("error method", () => { - const spinner = new Spinner("working"); + const spinner = new Spinner("working"); - spinner.error(); - expectCalls(spinner, { - errorCalls: ["working"] - }); + spinner.error(); + expectCalls(spinner, { + errorCalls: ["working"], + }); - const spinner2 = new Spinner("working"); - spinner2.error("failed"); - expectCalls(spinner2, { - errorCalls: ["failed"] - }); + const spinner2 = new Spinner("working"); + spinner2.error("failed"); + expectCalls(spinner2, { + errorCalls: ["failed"], + }); - const error = new Error("Something went wrong"); - const spinner3 = new Spinner("working"); - spinner3.error(error); - expectCalls(spinner3, { - errorCalls: [error] - }); + const error = new Error("Something went wrong"); + const spinner3 = new Spinner("working"); + spinner3.error(error); + expectCalls(spinner3, { + errorCalls: [error], + }); }); test("withSpinner function", async () => { - const expectedResult = Symbol("unique"); - let spin!: Spinner; + const expectedResult = Symbol("unique"); + let spin!: Spinner; - const result = await withSpinner("Loading data", async (spinner) => { - spin = spinner; - expect(spinner.text).toEqual("Loading data"); - return expectedResult; - }); + const result = await withSpinner("Loading data", async (spinner) => { + spin = spinner; + expect(spinner.text).toEqual("Loading data"); + return expectedResult; + }); - expect(result).toEqual(expectedResult); - expectCalls(spin, { - successCalls: ["Completed"], - }); + expect(result).toEqual(expectedResult); + expectCalls(spin, { + successCalls: ["Completed"], + }); }); test("withSpinner function with custom success text", async () => { - let spin!: Spinner; - const result = await withSpinner({ - text: "Loading", - successText: "Data loaded successfully" - }, async (spinner) => { - spin = spinner; - return "data"; - }); + let spin!: Spinner; + const result = await withSpinner({ + text: "Loading", + successText: "Data loaded successfully", + }, async (spinner) => { + spin = spinner; + return "data"; + }); - expect(result).toEqual("data"); - expectCalls(spin, { - successCalls: ["Data loaded successfully"], - }); + expect(result).toEqual("data"); + expectCalls(spin, { + successCalls: ["Data loaded successfully"], + }); }); test("withSpinner function with success text function", async () => { - let spin!: Spinner; - const result = await withSpinner({ - text: "Loading", - successText: (data: number[]) => `Loaded ${data.length} items` - }, async (spinner) => { - spin = spinner; - return [1, 2, 3]; - }); + let spin!: Spinner; + const result = await withSpinner({ + text: "Loading", + successText: (data: number[]) => `Loaded ${data.length} items`, + }, async (spinner) => { + spin = spinner; + return [1, 2, 3]; + }); - expect(result).toEqual([1, 2, 3]); - expectCalls(spin, { - successCalls: ["Loaded 3 items"], - }); + expect(result).toEqual([1, 2, 3]); + expectCalls(spin, { + successCalls: ["Loaded 3 items"], + }); }); test("withSpinner function with error", async () => { - const error = new Error("Failed to load data"); - let spin!: Spinner; + const error = new Error("Failed to load data"); + let spin!: Spinner; - try { - await withSpinner({ - text: "Loading", - failureText: "Could not load data" - }, async (spinner) => { - spin = spinner; - throw error; - }); - expect.unreachable(); - } catch (e) { - expect(e).toEqual(error); - expectCalls(spin, { - errorCalls: ["Could not load data"] - }); - } + try { + await withSpinner({ + text: "Loading", + failureText: "Could not load data", + }, async (spinner) => { + spin = spinner; + throw error; + }); + expect.unreachable(); + } catch (e) { + expect(e).toEqual(error); + expectCalls(spin, { + errorCalls: ["Could not load data"], + }); + } }); test("withSpinner function with error text function", async () => { - const error = new Error("Network error"); - let spin!: Spinner; + const error = new Error("Network error"); + let spin!: Spinner; - try { - await withSpinner({ - text: "Loading", - failureText: (err) => `Error: ${err.message}` - }, async (spinner) => { - spin = spinner; - throw error; - }); - expect.unreachable(); - } catch (e) { - expect(e).toEqual(error); - expectCalls(spin, { - errorCalls: ["Error: Network error"] - }); - } + try { + await withSpinner({ + text: "Loading", + failureText: (err) => `Error: ${err.message}`, + }, async (spinner) => { + spin = spinner; + throw error; + }); + expect.unreachable(); + } catch (e) { + expect(e).toEqual(error); + expectCalls(spin, { + errorCalls: ["Error: Network error"], + }); + } }); diff --git a/src/Spinner.ts b/src/Spinner.ts index 2e82561..c242ede 100644 --- a/src/Spinner.ts +++ b/src/Spinner.ts @@ -1,5 +1,5 @@ -import { Widget } from './Widget.ts'; -import { getColor, type CustomLoggerColor } from './internal.ts'; +import { Widget } from "./Widget.ts"; +import { type CustomLoggerColor, getColor } from "./internal.ts"; export interface SpinnerOptions> { /** Text displayed to the right of the spinner. */ @@ -15,13 +15,15 @@ export interface SpinnerOptions> { } export const defaultSpinnerOptions = { - text: 'Loading...', - color: 'blueBright', - frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + text: "Loading...", + color: "blueBright", + frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], fps: 12.5, } as const; -export class Spinner = Record> extends Widget { +export class Spinner< + Props extends Record = Record, +> extends Widget { readonly frames: readonly string[]; #text: string | ((props: Props) => string); #color: ((text: string) => string) | null; @@ -30,7 +32,7 @@ export class Spinner = Record | string) { super(); - if (typeof options === 'string') { + if (typeof options === "string") { options = { text: options }; } this.#text = options.text ?? defaultSpinnerOptions.text; @@ -43,7 +45,9 @@ export class Spinner = Record string)) { @@ -73,7 +77,7 @@ export class Spinner = Record): void; update(newMessage: string): void; update(newData: string | Partial) { - if (typeof newData === 'string') { + if (typeof newData === "string") { this.text = newData; } else { this.#props = { ...this.#props, ...newData }; @@ -84,18 +88,14 @@ export class Spinner = Record, T> /** Calls a function with a spinner. */ export async function withSpinner, T>( spinnerOptions: WithSpinnerOptions | string, - fn: (spinner: Spinner) => Promise + fn: (spinner: Spinner) => Promise, ): Promise; export async function withSpinner(opts: any, fn: any) { const spinner = new Spinner(opts); @@ -119,20 +119,22 @@ export async function withSpinner(opts: any, fn: any) { if (spinner.active) { spinner.success( opts.successText - ? typeof opts.successText === 'function' + ? typeof opts.successText === "function" ? opts.successText(result) : opts.successText : opts.text - ? typeof opts.text === 'function' - ? opts.text(spinner.props) - : opts.text - : 'Completed' + ? typeof opts.text === "function" + ? opts.text(spinner.props) + : opts.text + : "Completed", ); } return result; } catch (error: any) { spinner.error( - typeof opts.failureText === 'function' ? opts.failureText(error) : opts.failureText ?? error + typeof opts.failureText === "function" + ? opts.failureText(error) + : (opts.failureText ?? error), ); throw error; } diff --git a/src/Widget.ts b/src/Widget.ts index 185960f..9634582 100644 --- a/src/Widget.ts +++ b/src/Widget.ts @@ -1,17 +1,19 @@ -import ansi from 'ansi-escapes'; -import { error, success } from './console.ts'; -import { flushStderr, writeToStderr } from './internal.ts'; +import ansi from "ansi-escapes"; +import { error, success } from "./console.ts"; +import { flushStderr, writeToStderr } from "./internal.ts"; + +type Timer = ReturnType & { unref?: () => void }; const widgets: Widget[] = []; let widgetLineCount = 0; let widgetTimer: Timer | undefined; let redrawingThisTick = false; -const kInternalUpdate = Symbol('internalUpdate'); -const kInternalGetText = Symbol('internalGetText'); +const kInternalUpdate = Symbol("internalUpdate"); +const kInternalGetText = Symbol("internalGetText"); function onExit() { - errorAllWidgets('widget alive while process exiting'); + errorAllWidgets("widget alive while process exiting"); writeToStderr(ansi.cursorShow); flushStderr(); } @@ -29,7 +31,7 @@ export abstract class Widget { writeToStderr(ansi.cursorHide); widgetTimer = setInterval(redrawWidgetsNoWait, 1000 / 60); widgetTimer?.unref?.(); - process.on('exit', onExit); + process.on("exit", onExit); } } @@ -54,11 +56,11 @@ export abstract class Widget { widgets.splice(index, 1); redrawWidgetsSoon(); if (finalMessage) { - writeToStderr(finalMessage + '\n'); + writeToStderr(finalMessage + "\n"); } if (widgets.length === 0) { clearInterval(widgetTimer); - process.removeListener('exit', onExit); + process.removeListener("exit", onExit); widgetTimer = undefined; writeToStderr(ansi.cursorShow); redrawWidgetsNoWait(); @@ -72,23 +74,23 @@ export abstract class Widget { } #nextUpdate = 0; - #text = ''; + #text = ""; #newlines = 0; - [kInternalUpdate](now: number) { + [kInternalUpdate](now: number): boolean { if (now > this.#nextUpdate) { this.#nextUpdate = this.fps === 0 ? Infinity : now + 1000 / this.fps; const text = this.format(now); if (text !== this.#text) { - this.#text = text + '\n'; - this.#newlines = text.split('\n').length; + this.#text = text + "\n"; + this.#newlines = text.split("\n").length; } return true; } return false; } - [kInternalGetText]() { + [kInternalGetText](): string { widgetLineCount += this.#newlines; return this.#text; } @@ -105,7 +107,7 @@ export abstract class Widget { this.stop(); } - get active() { + get active(): boolean { return widgets.includes(this); } } @@ -113,8 +115,10 @@ export abstract class Widget { export function redrawWidgetsSoon() { if (widgetLineCount) { writeToStderr( - '\u001B[?2026h' + - ansi.eraseLine + (ansi.cursorUp(1) + ansi.eraseLine).repeat(widgetLineCount) + '\r', + "\u001B[?2026h" + + ansi.eraseLine + + (ansi.cursorUp(1) + ansi.eraseLine).repeat(widgetLineCount) + + "\r", true, ); widgetLineCount = 0; @@ -126,18 +130,19 @@ export function redrawWidgetsSoon() { function redrawWidgetsNoWait() { redrawingThisTick = false; const now = performance.now(); - const hasUpdate = widgets.filter(widget => widget[kInternalUpdate](now)).length > 0; + const hasUpdate = + widgets.filter((widget) => widget[kInternalUpdate](now)).length > 0; if (hasUpdate || widgetLineCount === 0) { redrawWidgetsSoon(); - writeToStderr(widgets.map(widget => widget[kInternalGetText]()).join('')); + writeToStderr(widgets.map((widget) => widget[kInternalGetText]()).join("")); } flushStderr(); } export function errorAllWidgets(reason: string) { for (const w of widgets) { - if ('text' in w) { + if ("text" in w) { w.error((w as any).text + ` (due to ${reason})`); } else { w.stop(); @@ -146,8 +151,8 @@ export function errorAllWidgets(reason: string) { } /** Writes raw line of text without a prefix or filtering. */ -export function writeLine(message = '') { +export function writeLine(message = "") { redrawWidgetsSoon(); - writeToStderr(message + '\n'); + writeToStderr(message + "\n"); if (!redrawingThisTick) flushStderr(); } diff --git a/src/console.test.ts b/src/console.test.ts index f3b0fde..59517a8 100644 --- a/src/console.test.ts +++ b/src/console.test.ts @@ -4,15 +4,17 @@ import chalk from "chalk"; beforeEach(reset); test("built-in log levels", () => { - const console = require("@paperclover/console"); - console.info("log"); - expect(getBuffer()).toEqual(`${chalk.blueBright.bold("info")} log\n`); - reset(); - console.warn("log"); - expect(getBuffer()).toEqual(`${chalk.yellowBright.bold("warn")} ${chalk.yellowBright("log")}\n`); - reset(); - console.error("log"); - // Don't check the exact color formatting as it may vary - expect(getBuffer()).toContain("✖"); - expect(getBuffer()).toContain("log"); + const console = require("@paperclover/console"); + console.info("log"); + expect(getBuffer()).toEqual(`${chalk.blueBright.bold("info")} log\n`); + reset(); + console.warn("log"); + expect(getBuffer()).toEqual( + `${chalk.yellowBright.bold("warn")} ${chalk.yellowBright("log")}\n`, + ); + reset(); + console.error("log"); + // Don't check the exact color formatting as it may vary + expect(getBuffer()).toContain("✖"); + expect(getBuffer()).toContain("log"); }); diff --git a/src/console.ts b/src/console.ts index 83c55e6..ef402df 100644 --- a/src/console.ts +++ b/src/console.ts @@ -1,71 +1,141 @@ -export const isUnicodeSupported = - process.platform === 'win32' - ? Boolean(process.env.CI) || - Boolean(process.env.WT_SESSION) || // Windows Terminal - process.env.ConEmuTask === '{cmd::Cmder}' || // ConEmu and cmder - process.env.TERM_PROGRAM === 'vscode' || - process.env.TERM === 'xterm-256color' || - process.env.TERM === 'alacritty' || - process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm' - : process.env.TERM !== 'linux'; +export const isUnicodeSupported: boolean = process.platform === "win32" + ? Boolean(process.env.CI) || + Boolean(process.env.WT_SESSION) || // Windows Terminal + process.env.ConEmuTask === "{cmd::Cmder}" || // ConEmu and cmder + process.env.TERM_PROGRAM === "vscode" || + process.env.TERM === "xterm-256color" || + process.env.TERM === "alacritty" || + process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm" + : process.env.TERM !== "linux"; -export const errorSymbol = isUnicodeSupported ? '✖' : 'x'; -export const successSymbol = isUnicodeSupported ? '✔' : '√'; -export const infoSymbol = isUnicodeSupported ? 'ℹ' : 'i'; -export const warningSymbol = isUnicodeSupported ? '⚠' : '‼'; +export const errorSymbol = isUnicodeSupported ? "✖" : "x"; +export const successSymbol = isUnicodeSupported ? "✔" : "√"; +export const infoSymbol = isUnicodeSupported ? "ℹ" : "i"; +export const warningSymbol = isUnicodeSupported ? "⚠" : "‼"; let filters: string[] = []; let filterGeneration = 0; export function setLogFilter(...newFilters: Array) { - filters = newFilters.flat().map(filter => filter.toLowerCase()); - filterGeneration++; + filters = newFilters.flat().map((filter) => filter.toLowerCase()); + filterGeneration++; } -export function isLogVisible(id: string, defaultVisibility = true) { - for (const filter of filters) { - if (filter === '*') { - defaultVisibility = true; - } else if (filter === '-*') { - defaultVisibility = false; - } else if (filter === id || id.startsWith(filter + ':')) { - defaultVisibility = true; - } else if (filter === '-' + id || id.startsWith('-' + filter + ':')) { - defaultVisibility = false; - } +export function isLogVisible(id: string, defaultVisibility = true): boolean { + for (const filter of filters) { + if (filter === "*") { + defaultVisibility = true; + } else if (filter === "-*") { + defaultVisibility = false; + } else if (filter === id || id.startsWith(filter + ":")) { + defaultVisibility = true; + } else if (filter === "-" + id || id.startsWith("-" + filter + ":")) { + defaultVisibility = false; } - return defaultVisibility; + } + return defaultVisibility; } if (process.env.DEBUG !== undefined) { - setLogFilter( - String(process.env.DEBUG) - .split(',') - .map(x => x.trim()) - .map(x => (['1', 'true', 'all'].includes(x.toLowerCase()) ? '*' : x)) - ); + setLogFilter( + String(process.env.DEBUG) + .split(",") + .map((x) => x.trim()) + .map((x) => (["1", "true", "all"].includes(x.toLowerCase()) ? "*" : x)), + ); } - /** Taken from https://github.com/debug-js/debug/blob/d1616622e4d404863c5a98443f755b4006e971dc/src/node.js#L35. */ const debugColors = [ - 20, 21, 26, 27, 32, 33, 38, 39, 40, 41, 42, 43, 44, 45, 56, 57, 62, 63, 68, 69, 74, 75, 76, 77, - 78, 79, 80, 81, 92, 93, 98, 99, 112, 113, 128, 129, 134, 135, 148, 149, 160, 161, 162, 163, 164, - 165, 166, 167, 168, 169, 170, 171, 172, 173, 178, 179, 184, 185, 196, 197, 198, 199, 200, 201, - 202, 203, 204, 205, 206, 207, 208, 209, 214, 215, 220, 221, + 20, + 21, + 26, + 27, + 32, + 33, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 56, + 57, + 62, + 63, + 68, + 69, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 92, + 93, + 98, + 99, + 112, + 113, + 128, + 129, + 134, + 135, + 148, + 149, + 160, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 178, + 179, + 184, + 185, + 196, + 197, + 198, + 199, + 200, + 201, + 202, + 203, + 204, + 205, + 206, + 207, + 208, + 209, + 214, + 215, + 220, + 221, ]; /** Converts non string objects into a string the way Node.js' console.log does it. */ function stringify(...data: any[]) { - return data - .map(obj => { - if (typeof obj === 'string') { - return obj; - } else if (obj instanceof Error) { - return formatErrorObj(obj); - } - return inspect(obj, false, 4, true); - }) - .join(' '); + return data + .map((obj) => { + if (typeof obj === "string") { + return obj; + } else if (obj instanceof Error) { + return formatErrorObj(obj); + } + return inspect(obj, false, 4, true); + }) + .join(" "); } /** @@ -74,56 +144,55 @@ function stringify(...data: any[]) { * Taken from https://github.com/debug-js/debug/blob/master/src/common.js. */ function selectColor(namespace: string) { - let hash = 0; + let hash = 0; - for (let i = 0; i < namespace.length; i++) { - hash = (hash << 5) - hash + namespace.charCodeAt(i); - hash |= 0; // Convert to 32bit integer - } + for (let i = 0; i < namespace.length; i++) { + hash = (hash << 5) - hash + namespace.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } - return debugColors[Math.abs(hash) % debugColors.length]!; + return debugColors[Math.abs(hash) % debugColors.length]!; } - - const formatImplementation = { - 's': (data: StringLike) => String(data), - 'd': (data: number) => String(data), - 'i': (data: number) => String(Math.floor(data)), - 'f': (data: number) => String(data), - 'x': (data: number) => data.toString(16), - 'X': (data: number) => data.toString(16).toUpperCase(), - 'o': (data: any) => JSON.stringify(data), - 'O': (data: any) => JSON.stringify(data, null, 2), - 'c': () => '', - 'j': (data: any) => JSON.stringify(data), + s: (data: StringLike) => String(data), + d: (data: number) => String(data), + i: (data: number) => String(Math.floor(data)), + f: (data: number) => String(data), + x: (data: number) => data.toString(16), + X: (data: number) => data.toString(16).toUpperCase(), + o: (data: any) => JSON.stringify(data), + O: (data: any) => JSON.stringify(data, null, 2), + c: () => "", + j: (data: any) => JSON.stringify(data), }; function format(fmt: any, ...args: any[]) { - if (typeof fmt === 'string') { - let index = 0; - const result = fmt.replace(/%[%sdifoxXcj]/g, match => { - if (match === '%%') { - return '%'; - } - const arg = args[index++]; - return (formatImplementation as any)[match[1]!](arg); - }); + if (typeof fmt === "string") { + let index = 0; + const result = fmt.replace(/%[%sdifoxXcj]/g, (match) => { + if (match === "%%") { + return "%"; + } + const arg = args[index++]; + return (formatImplementation as any)[match[1]!](arg); + }); - if (index === 0 && args.length > 0) { - return result + ' ' + stringify(...args); - } - - return result; + if (index === 0 && args.length > 0) { + return result + " " + stringify(...args); } - return stringify(fmt, ...args); + + return result; + } + return stringify(fmt, ...args); } -const LogFunction = { - __proto__: Function.prototype, - [Symbol.for('nodejs.util.inspect.custom')](depth: number, options: any) { - return options.stylize(`[LogFunction: ${(this as any).name}]`, 'special'); - }, +/** @internal */ +const LogFunction: {} = { + __proto__: Function.prototype, + [Symbol.for("nodejs.util.inspect.custom")](depth: number, options: any) { + return options.stylize(`[LogFunction: ${(this as any).name}]`, "special"); + }, }; /** @@ -138,108 +207,124 @@ const LogFunction = { * - Using chalk or another formatter on the namespace name. */ export function scoped( - name: string, - opts: CustomLoggerOptions | CustomLoggerColor = {} + name: string, + opts: CustomLoggerOptions | CustomLoggerColor = {}, ): LogFunction { - if (typeof opts === 'string' || Array.isArray(opts) || typeof opts === 'number') { - opts = { color: opts }; + if ( + typeof opts === "string" || + Array.isArray(opts) || + typeof opts === "number" + ) { + opts = { color: opts }; + } + const { + id = name, + color = undefined, + coloredText = false, + boldText = false, + debug = false, + } = opts; + const strippedName = stripAnsi(name); + const colorFn = name.includes("\x1b") + ? chalk + : color + ? getColor(color) + : chalk.ansi256(selectColor(name)); + const coloredName = colorFn.bold(name); + const fn = ((fmt: unknown, ...args: any[]) => { + if (!fn.visible) { + return; } - const { - id = name, - color = undefined, - coloredText = false, - boldText = false, - debug = false, - } = opts; - const strippedName = stripAnsi(name); - const colorFn = name.includes('\x1b') - ? chalk - : color - ? getColor(color) - : chalk.ansi256(selectColor(name)); - const coloredName = colorFn.bold(name); - const fn = ((fmt: unknown, ...args: any[]) => { - if (!fn.visible) { - return; - } - const data = format(fmt, ...args).replace(/\n/g, '\n ' + ' '.repeat(strippedName.length)); + const data = format(fmt, ...args).replace( + /\n/g, + "\n " + " ".repeat(strippedName.length), + ); - if (fmt === undefined && args.length === 0) { - writeLine(); - } else { - writeLine( - coloredName + ' ' + (coloredText ? (boldText ? colorFn.bold(data) : colorFn(data)) : data) - ); - } - }) as LogFunction; - Object.setPrototypeOf(fn, LogFunction); - Object.defineProperty(fn, 'name', { value: id }); - let gen = filterGeneration; - let visible = isLogVisible(id, !debug); - Object.defineProperty(fn, 'visible', { - get: () => { - if (gen !== filterGeneration) { - gen = filterGeneration; - visible = isLogVisible(id, !debug); - } - return visible; - } - }); - return fn; + if (fmt === undefined && args.length === 0) { + writeLine(); + } else { + writeLine( + coloredName + + " " + + (coloredText ? boldText ? colorFn.bold(data) : colorFn(data) : data), + ); + } + }) as LogFunction; + Object.setPrototypeOf(fn, LogFunction); + Object.defineProperty(fn, "name", { value: id }); + let gen = filterGeneration; + let visible = isLogVisible(id, !debug); + Object.defineProperty(fn, "visible", { + get: () => { + if (gen !== filterGeneration) { + gen = filterGeneration; + visible = isLogVisible(id, !debug); + } + return visible; + }, + }); + return fn; } - /** Built in blue "info" logger. */ -export const info = /* @__PURE__ */ scoped('info', { - color: 'blueBright', +export const info: LogFunction = /* @__PURE__ */ scoped("info", { + color: "blueBright", }); /** Built in yellow "warn" logger. */ -export const warn = /* @__PURE__ */ scoped('warn', { - color: 'yellowBright', - coloredText: true, +export const warn: LogFunction = /* @__PURE__ */ scoped("warn", { + color: "yellowBright", + coloredText: true, }); -const _trace = /* @__PURE__ */ scoped('trace', { - color: 208, +const _trace: LogFunction = /* @__PURE__ */ scoped("trace", { + color: 208, }); /** Built in orange "trace" logger. Prints a stack trace after the message. */ -export const trace = /* @__PURE__ */ ((trace: any) => (Object.defineProperty(trace, 'visible', { get: () => _trace.visible }), trace))(function trace(...data: any[]) { - if (_trace.visible) { - _trace(...(data.length === 0 ? [' '] : data)); - writeLine(formatStackTrace(new Error()).split('\n').slice(1).join('\n')); - } +export const trace: LogFunction = /* @__PURE__ */ ((trace: any) => ( + Object.defineProperty(trace, "visible", { get: () => _trace.visible }), trace +))(function trace(...data: any[]) { + if (_trace.visible) { + _trace(...(data.length === 0 ? [" "] : data)); + writeLine(formatStackTrace(new Error()).split("\n").slice(1).join("\n")); + } }) as typeof _trace; /** Built in red "error" logger, uses a unicode X instead of the word Error. */ -export const error = /* @__PURE__ */ scoped(errorSymbol, { - id: 'error', - color: 'redBright', - coloredText: true, - boldText: true, +export const error: LogFunction = /* @__PURE__ */ scoped(errorSymbol, { + id: "error", + color: "redBright", + coloredText: true, + boldText: true, }); /** Built in cyan "debug" logger. */ -export const debug = scoped('debug', { - color: 'cyanBright', - debug: true, +export const debug: LogFunction = scoped("debug", { + color: "cyanBright", + debug: true, }); /** Built in green "success" logger, uses a unicode Check instead of the word Success. */ -export const success = /* @__PURE__ */ scoped(successSymbol, { - id: 'success', - color: 'greenBright', - coloredText: true, - boldText: true, +export const success: LogFunction = /* @__PURE__ */ scoped(successSymbol, { + id: "success", + color: "greenBright", + coloredText: true, + boldText: true, }); -import chalk, { type ChalkInstance } from 'chalk'; -import { inspect } from 'node:util'; -import { type LogFunction, type CustomLoggerColor, type CustomLoggerOptions, type StringLike, getColor } from './internal.ts'; -import { formatErrorObj, formatStackTrace } from './error.ts'; -import stripAnsi from 'strip-ansi'; -import { writeLine } from './Widget.ts'; +import chalk, { type ChalkInstance } from "chalk"; +import { inspect } from "node:util"; +import { + type CustomLoggerColor, + type CustomLoggerOptions, + getColor, + type LogFunction, + type StringLike, +} from "./internal.ts"; +import { formatErrorObj, formatStackTrace } from "./error.ts"; +import stripAnsi from "strip-ansi"; +import { writeLine } from "./Widget.ts"; -export { writeLine } from './Widget.ts'; +export { writeLine } from "./Widget.ts"; diff --git a/src/error.test.ts b/src/error.test.ts index d02c392..d987b04 100644 --- a/src/error.test.ts +++ b/src/error.test.ts @@ -1,5 +1,10 @@ import { expect, test } from "bun:test"; -import { formatErrorObj, formatStackTrace, CLIError, platformSimplifyErrorPath } from "./error.ts"; +import { + CLIError, + formatErrorObj, + formatStackTrace, + platformSimplifyErrorPath, +} from "./error.ts"; import chalk from "chalk"; test("formatErrorObj formats basic errors", () => { @@ -16,9 +21,9 @@ test("formatErrorObj with PrintableError interface", () => { const printableError = new Error("Custom message") as any; printableError.name = "CustomError"; printableError.description = "This is a detailed description of the error"; - + const formatted = formatErrorObj(printableError); - + // Should contain name, message and description expect(formatted).toContain("CustomError: Custom message"); expect(formatted).toContain("This is a detailed description of the error"); @@ -28,9 +33,9 @@ test("formatErrorObj with PrintableError interface", () => { test("formatErrorObj with hideName option", () => { const printableError = new Error("Just the message") as any; printableError.hideName = true; - + const formatted = formatErrorObj(printableError); - + // Should not contain "Error:" expect(formatted).not.toContain("Error:"); // Don't check the exact output since the stack trace might vary @@ -39,9 +44,9 @@ test("formatErrorObj with hideName option", () => { test("formatErrorObj with hideStack option", () => { const printableError = new Error("No stack trace") as any; printableError.hideStack = true; - + const formatted = formatErrorObj(printableError); - + // Should contain message but not stack trace expect(formatted).toContain("Error: No stack trace"); expect(formatted).not.toContain("at "); @@ -50,7 +55,7 @@ test("formatErrorObj with hideStack option", () => { test("formatStackTrace with V8 style stack", () => { const error = new Error("Stack trace test"); const formatted = formatStackTrace(error); - + // Should format the stack trace expect(formatted).toContain("at "); // May not have color codes in test environment @@ -59,9 +64,9 @@ test("formatStackTrace with V8 style stack", () => { test("formatStackTrace with empty stack", () => { const error = new Error("No stack"); error.stack = undefined; - + const formatted = formatStackTrace(error); - + // Should handle undefined stack expect(formatted).toEqual(""); }); @@ -70,7 +75,7 @@ test("platformSimplifyErrorPath with cwd", () => { const cwd = process.cwd(); const fullPath = `${cwd}/src/error.ts`; const simplified = platformSimplifyErrorPath(fullPath); - + // Should convert to a relative path expect(simplified).toEqual("./src/error.ts"); }); @@ -78,7 +83,7 @@ test("platformSimplifyErrorPath with cwd", () => { test("platformSimplifyErrorPath with external path", () => { const externalPath = "/usr/local/lib/node_modules/module.js"; const simplified = platformSimplifyErrorPath(externalPath); - + // Should keep external paths unchanged expect(simplified).toEqual(externalPath); }); @@ -86,18 +91,22 @@ test("platformSimplifyErrorPath with external path", () => { test("CLIError class", () => { const cliError = new CLIError( "Command failed", - "Try running with --help for available options" + "Try running with --help for available options", ); - + expect(cliError.message).toEqual("Command failed"); - expect(cliError.description).toEqual("Try running with --help for available options"); + expect(cliError.description).toEqual( + "Try running with --help for available options", + ); expect(cliError.hideStack).toEqual(true); expect(cliError.hideName).toEqual(true); - + const formatted = formatErrorObj(cliError); - + // Should format according to the PrintableError interface - expect(formatted).toEqual("Command failed\nTry running with --help for available options\n"); + expect(formatted).toEqual( + "Command failed\nTry running with --help for available options\n", + ); expect(formatted).not.toContain("CLIError"); expect(formatted).not.toContain("at "); }); @@ -109,9 +118,9 @@ test("pretty printing of objects in stack traces", () => { JSON.parse("{invalid}"); } catch (error) { const formatted = formatErrorObj(error as Error); - + // Should format the error message expect(formatted).toContain("SyntaxError"); expect(formatted).toContain("at "); } -}); \ No newline at end of file +}); diff --git a/src/error.ts b/src/error.ts index ef1d629..bc6fea7 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,11 +1,11 @@ -import chalk from 'chalk'; -import path from 'node:path'; -import { isBuiltin } from 'node:module'; +import chalk from "chalk"; +import path from "node:path"; +import { isBuiltin } from "node:module"; -export function platformSimplifyErrorPath(filepath: string) { +export function platformSimplifyErrorPath(filepath: string): string { const cwd = process.cwd(); if (filepath.startsWith(cwd)) { - return '.' + filepath.slice(cwd.length); + return "." + filepath.slice(cwd.length); } return filepath; } @@ -25,60 +25,65 @@ export interface PrintableError extends Error { } /** Utility function we use internally for formatting the stack trace of an error. */ -export function formatStackTrace(err: Error) { +export function formatStackTrace(err: Error): string { if (!err.stack) { - return ''; + return ""; } - const v8firstLine = `${err.name}${err.message ? ': ' + err.message : ''}\n`; + const v8firstLine = `${err.name}${err.message ? ": " + err.message : ""}\n`; const parsed = err.stack.startsWith(v8firstLine) ? err.stack .slice(v8firstLine.length) - .split('\n') - .map(line => { + .split("\n") + .map((line) => { const match = /at (.*) \((.*):(\d+):(\d+)\)/.exec(line); if (!match) { const match2 = /at (.*):(\d+):(\d+)/.exec(line); if (match2) { return { - method: '', + method: "", file: match2[1], line: match2[2], column: match2[3], }; } - return { method: '', file: null, line: null, column: null }; + return { + method: "", + file: null, + line: null, + column: null, + }; } return { method: match[1], file: match[2], - line: parseInt(match[3] ?? '0', 10), - column: parseInt(match[4] ?? '0', 10), - native: line.includes('[native code]'), + line: parseInt(match[3] ?? "0", 10), + column: parseInt(match[4] ?? "0", 10), + native: line.includes("[native code]"), }; }) - : err.stack.split('\n').map(line => { - const at = line.indexOf('@'); + : err.stack.split("\n").map((line) => { + const at = line.indexOf("@"); const method = line.slice(0, at); const file = line.slice(at + 1); const fileSplit = /^(.*?):(\d+):(\d+)$/.exec(file); return { - method: (['module code'].includes(method) ? '' : method) || '', - file: fileSplit ? platformSimplifyErrorPath(fileSplit[1] ?? '') : null, - line: fileSplit ? parseInt(fileSplit[2] ?? '0', 10) : null, - column: fileSplit ? parseInt(fileSplit[3] ?? '0', 10) : null, - native: file === '[native code]', + method: (["module code"].includes(method) ? "" : method) || "", + file: fileSplit ? platformSimplifyErrorPath(fileSplit[1] ?? "") : null, + line: fileSplit ? parseInt(fileSplit[2] ?? "0", 10) : null, + column: fileSplit ? parseInt(fileSplit[3] ?? "0", 10) : null, + native: file === "[native code]", }; }); const nodeModuleJobIndex = parsed.findIndex( - line => line.file === 'node:internal/modules/esm/module_job' + (line) => line.file === "node:internal/modules/esm/module_job", ); if (nodeModuleJobIndex !== -1) { parsed.splice(nodeModuleJobIndex, Infinity); } parsed.reverse(); - const sliceAt = parsed.findIndex(line => !line.native); + const sliceAt = parsed.findIndex((line) => !line.native); if (sliceAt !== -1) { // remove the first native lines parsed.splice(0, sliceAt); @@ -88,12 +93,11 @@ export function formatStackTrace(err: Error) { return parsed .map(({ method, file, line, column, native }) => { function getColoredDirname(filename: string) { - const dirname = - process.platform === 'win32' - ? path.dirname(filename).replace(/^file:\/\/\//g, '') - : path.dirname(filename).replace(/^file:\/\//g, '') + path.sep; + const dirname = process.platform === "win32" + ? path.dirname(filename).replace(/^file:\/\/\//g, "") + : path.dirname(filename).replace(/^file:\/\//g, "") + path.sep; - if (dirname === '/' || dirname === './') { + if (dirname === "/" || dirname === "./") { return dirname; } return chalk.cyan(dirname); @@ -102,35 +106,36 @@ export function formatStackTrace(err: Error) { const source = native ? `[native code]` : file - ? isBuiltin(file) - ? `(${chalk.magenta(file)})` - : [ - '(', - getColoredDirname(file), - // Leave the first slash on linux. - chalk.green(path.basename(file)), - ':', - line + ':' + column, - ')', - ].join('') - : ''; + ? isBuiltin(file) ? `(${chalk.magenta(file)})` : [ + "(", + getColoredDirname(file), + // Leave the first slash on linux. + chalk.green(path.basename(file)), + ":", + line + ":" + column, + ")", + ].join("") + : ""; - return chalk.blackBright(` at ${method === '' ? '' : `${method} `}${source}`); + return chalk.blackBright( + ` at ${method === "" ? "" : `${method} `}${source}`, + ); }) - .join('\n'); + .join("\n"); } /** Formats the given error as a full log string. */ -export function formatErrorObj(err: Error | PrintableError) { - const { name, message, description, hideStack, hideName, stack } = err as PrintableError; +export function formatErrorObj(err: Error | PrintableError): string { + const { name, message, description, hideStack, hideName, stack } = + err as PrintableError; return [ - hideName ? '' : (name ?? 'Error') + ': ', - message ?? 'Unknown error', - description ? '\n' + description : '', - hideStack || !stack ? '' : '\n' + chalk.reset(formatStackTrace(err)), - description || (!hideStack && stack) ? '\n' : '', - ].join(''); + hideName ? "" : (name ?? "Error") + ": ", + message ?? "Unknown error", + description ? "\n" + description : "", + hideStack || !stack ? "" : "\n" + chalk.reset(formatStackTrace(err)), + description || (!hideStack && stack) ? "\n" : "", + ].join(""); } /** @@ -158,15 +163,15 @@ export class CLIError extends Error implements PrintableError { constructor(message: string, description: string) { super(message); - this.name = 'CLIError'; + this.name = "CLIError"; this.description = description; } - get hideStack() { + get hideStack(): boolean { return true; } - get hideName() { + get hideName(): boolean { return true; } } diff --git a/src/inject.test.ts b/src/inject.test.ts index 5f4e591..0b35a28 100644 --- a/src/inject.test.ts +++ b/src/inject.test.ts @@ -1,33 +1,36 @@ -import { beforeEach, expect, test, mock } from "bun:test"; +import { beforeEach, expect, mock, test } from "bun:test"; import { spawn } from "child_process"; // We need to use a subprocess to test injection since it happens immediately on import // and would affect our test environment -function runSubprocess(testType: string, env: Record = {}): Promise { +function runSubprocess( + testType: string, + env: Record = {}, +): Promise { return new Promise((resolve, reject) => { // Merge the current process.env with any additional env variables const environment = { ...process.env, ...env }; - + const subprocess = spawn("bun", ["src/test/subprocess.ts", testType], { - env: environment + env: environment, }); - + let stdout = ""; let stderr = ""; - + subprocess.stdout.on("data", (data) => { stdout += data.toString(); }); - + subprocess.stderr.on("data", (data) => { stderr += data.toString(); }); - + subprocess.on("error", (error) => { reject(error); }); - + subprocess.on("close", (code) => { if (code !== 0) { reject(new Error(`Subprocess exited with code ${code}: ${stderr}`)); @@ -40,18 +43,18 @@ function runSubprocess(testType: string, env: Record = {}): Prom test("basic injection is applied correctly", async () => { // Enable debug logs with DEBUG=1 and force color with FORCE_COLOR - const output = await runSubprocess("basic-injection", { + const output = await runSubprocess("basic-injection", { DEBUG: "1", - FORCE_COLOR: "1" + FORCE_COLOR: "1", }); - + // Check that console methods are correctly injected expect(output).toContain("INJECTED_LOG"); expect(output).toContain("INJECTED_INFO"); expect(output).toContain("INJECTED_WARN"); expect(output).toContain("INJECTED_ERROR"); expect(output).toContain("INJECTED_DEBUG"); - + // Assert should only output when condition is false expect(output).toContain("INJECTED_ASSERT"); expect(output).not.toContain("This should not appear"); @@ -59,18 +62,18 @@ test("basic injection is applied correctly", async () => { test("timer functions work correctly", async () => { const output = await runSubprocess("time-functions"); - + // Check timer functions expect(output).toContain("TIMER_LABEL"); expect(output).toContain("ms]"); // Time output should include milliseconds - + // Warning for non-existent timer expect(output).toContain("does not exist"); }); test("count functions work correctly", async () => { const output = await runSubprocess("count-functions"); - + // Check counter functions expect(output).toContain("COUNTER_LABEL: 1"); expect(output).toContain("COUNTER_LABEL: 2"); @@ -79,9 +82,9 @@ test("count functions work correctly", async () => { test("environment variables affect injection", async () => { const output = await runSubprocess("disabled-injection", { NO_COLOR: "1" }); - + // When NO_COLOR is set, output should still have the message expect(output).toContain("DISABLED_COLOR_LOG"); // Note: Chalk may still output color codes even with NO_COLOR=1 in some environments // So we're not checking for absence of color codes here -}); \ No newline at end of file +}); diff --git a/src/inject.ts b/src/inject.ts index 601060d..4a3ce73 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -1,7 +1,7 @@ -import chalk from 'chalk'; -import { debug, error, info, trace, warn } from './console.ts'; -import { Spinner } from './Spinner.ts'; -import { errorAllWidgets, writeLine } from './Widget.ts'; +import chalk from "chalk"; +import { debug, error, info, trace, warn } from "./console.ts"; +import { Spinner } from "./Spinner.ts"; +import { errorAllWidgets, writeLine } from "./Widget.ts"; // Basic Logging Functions console.log = info; @@ -38,7 +38,9 @@ console.timeEnd = (label: string) => { } const { start, spinner } = timers.get(label)!; timers.delete(label); - spinner.success(label + chalk.blackBright(` [${(performance.now() - start).toFixed(3)}ms]`)); + spinner.success( + label + chalk.blackBright(` [${(performance.now() - start).toFixed(3)}ms]`), + ); }; console.timeLog = (label: string) => { if (!timers.has(label)) { @@ -46,7 +48,9 @@ console.timeLog = (label: string) => { return; } const { start } = timers.get(label)!; - console.log(label + chalk.blackBright(` [${(performance.now() - start).toFixed(3)}ms]`)); + console.log( + label + chalk.blackBright(` [${(performance.now() - start).toFixed(3)}ms]`), + ); }; const counters = new Map(); @@ -61,20 +65,22 @@ console.countReset = (label: string) => { console.trace = trace; -process.on('uncaughtException', (exception: any) => { - errorAllWidgets('uncaught exception'); +process.on("uncaughtException", (exception: any) => { + errorAllWidgets("uncaught exception"); error(exception); - writeLine('The above error was not caught by a catch block, execution cannot continue.'); + writeLine( + "The above error was not caught by a catch block, execution cannot continue.", + ); process.exit(1); }); -process.on('unhandledRejection', (reason: any) => { - errorAllWidgets('unhandled rejection'); +process.on("unhandledRejection", (reason: any) => { + errorAllWidgets("unhandled rejection"); error(reason); writeLine( - '\nThe above error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch()' + "\nThe above error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch()", ); process.exit(1); -}); \ No newline at end of file +}); diff --git a/src/internal.ts b/src/internal.ts index c080cef..0e98452 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -1,164 +1,170 @@ -import chalk, { type ChalkInstance } from 'chalk'; -import { inspect } from 'node:util'; +import chalk, { type ChalkInstance } from "chalk"; +import { inspect } from "node:util"; -export function convertHSVtoRGB(h: number, s: number, v: number): [number, number, number] { - let r, g, b; - const i = Math.floor(h * 6); - const f = h * 6 - i; - const p = v * (1 - s); - const q = v * (1 - f * s); - const t = v * (1 - (1 - f) * s); - switch (i % 6) { - case 0: - (r = v), (g = t), (b = p); - break; - case 1: - (r = q), (g = v), (b = p); - break; - case 2: - (r = p), (g = v), (b = t); - break; - case 3: - (r = p), (g = q), (b = v); - break; - case 4: - (r = t), (g = p), (b = v); - break; - case 5: - (r = v), (g = p), (b = q); - break; - default: - return [0, 0, 0]; - } - return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; +export function convertHSVtoRGB( + h: number, + s: number, + v: number, +): [number, number, number] { + let r, g, b; + const i = Math.floor(h * 6); + const f = h * 6 - i; + const p = v * (1 - s); + const q = v * (1 - f * s); + const t = v * (1 - (1 - f) * s); + switch (i % 6) { + case 0: + (r = v), (g = t), (b = p); + break; + case 1: + (r = q), (g = v), (b = p); + break; + case 2: + (r = p), (g = v), (b = t); + break; + case 3: + (r = p), (g = q), (b = v); + break; + case 4: + (r = t), (g = p), (b = v); + break; + case 5: + (r = v), (g = p), (b = q); + break; + default: + return [0, 0, 0]; + } + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; } /** Converts non string objects into a string the way Node.js' console.log does it. */ export function stringify(...data: any[]) { - return data.map(obj => (typeof obj === 'string' ? obj : inspect(obj, false, 4, true))).join(' '); + return data.map( + (obj) => (typeof obj === "string" ? obj : inspect(obj, false, 4, true)), + ).join(" "); } -let buffer = ''; +let buffer = ""; let bufferNeedsUnfreeze = false; let exiting = false; export function writeToStderr(data: string, needsUnfreeze = false) { - buffer += data; - if (exiting) flushStderr(); - if (needsUnfreeze) bufferNeedsUnfreeze = true; + buffer += data; + if (exiting) flushStderr(); + if (needsUnfreeze) bufferNeedsUnfreeze = true; } const stderr = process.stderr; export function flushStderr() { - if (buffer) { - if (bufferNeedsUnfreeze) { - buffer += '\u001B[?2026l'; - bufferNeedsUnfreeze = false; - } - stderr.write(buffer); - buffer = ''; + if (buffer) { + if (bufferNeedsUnfreeze) { + buffer += "\u001B[?2026l"; + bufferNeedsUnfreeze = false; } + stderr.write(buffer); + buffer = ""; + } } -process.on('exit', () => { - exiting = true; - buffer += '\x1b[0;39;49m'; - flushStderr(); +process.on("exit", () => { + exiting = true; + buffer += "\x1b[0;39;49m"; + flushStderr(); }); export type CustomLoggerColor = - | 'black' - | 'red' - | 'green' - | 'yellow' - | 'blue' - | 'magenta' - | 'cyan' - | 'white' - | 'gray' - | 'grey' - | 'blackBright' - | 'redBright' - | 'greenBright' - | 'yellowBright' - | 'blueBright' - | 'magentaBright' - | 'cyanBright' - | 'whiteBright' - | `#${string}` - | number - | [number, number, number]; + | "black" + | "red" + | "green" + | "yellow" + | "blue" + | "magenta" + | "cyan" + | "white" + | "gray" + | "grey" + | "blackBright" + | "redBright" + | "greenBright" + | "yellowBright" + | "blueBright" + | "magentaBright" + | "cyanBright" + | "whiteBright" + | `#${string}` + | number + | [number, number, number]; export interface CustomLoggerOptions { - id?: string; - color?: CustomLoggerColor; - coloredText?: boolean; - boldText?: boolean; - level?: number; - debug?: boolean; + id?: string; + color?: CustomLoggerColor; + coloredText?: boolean; + boldText?: boolean; + level?: number; + debug?: boolean; } /** Matches `string`, `number`, and other objects with a `.toString()` method. */ export interface StringLike { - toString(): string; + toString(): string; } export interface FormatStringArgs { - '%s': StringLike | null | undefined; - '%d': number | null | undefined; - '%i': number | null | undefined; - '%f': number | null | undefined; - '%x': number | null | undefined; - '%X': number | null | undefined; - '%o': any; - '%O': any; - '%c': string | null | undefined; - '%j': any; + "%s": StringLike | null | undefined; + "%d": number | null | undefined; + "%i": number | null | undefined; + "%f": number | null | undefined; + "%x": number | null | undefined; + "%X": number | null | undefined; + "%o": any; + "%O": any; + "%c": string | null | undefined; + "%j": any; } export type ProcessFormatString = S extends `${string}%${infer K}${infer B}` - ? `%${K}` extends keyof FormatStringArgs + ? `%${K}` extends keyof FormatStringArgs ? [FormatStringArgs[`%${K}`], ...ProcessFormatString] - : ProcessFormatString - : []; + : ProcessFormatString + : []; export type LogData = string | number | boolean | object | null | undefined; export interface LogFunction { - /** - * Writes data to the log. The first argument can be a printf-style format string, or usage - * similar to `console.log`. Handles formatting objects including `Error` objects with pretty - * colorized stack traces. - * - * List of formatters: - * - * - %s - String. - * - %d, %f - Number. - * - %i - Integer. - * - %x - Hex. - * - %X - Hex (uppercase) - * - %o - Object. - * - %O - Object (pretty printed). - * - %j - JSON. - */ - (data?: S, ...a: ProcessFormatString): void; - /** Calling a logger function with no arguments prints a blank line. */ - (): void; + /** + * Writes data to the log. The first argument can be a printf-style format string, or usage + * similar to `console.log`. Handles formatting objects including `Error` objects with pretty + * colorized stack traces. + * + * List of formatters: + * + * - %s - String. + * - %d, %f - Number. + * - %i - Integer. + * - %x - Hex. + * - %X - Hex (uppercase) + * - %o - Object. + * - %O - Object (pretty printed). + * - %j - JSON. + */ + (data?: S, ...a: ProcessFormatString): void; + /** Calling a logger function with no arguments prints a blank line. */ + (): void; - visible: boolean; - name: string; + visible: boolean; + name: string; } export function getColor(color: CustomLoggerColor): ChalkInstance { - if (typeof color === 'string') { - if (color in chalk) { - return (chalk as any)[color]; - } else if (color.startsWith('#') || color.match(/^#[0-9a-fA-F]{6}$/)) { - return chalk.hex(color); - } - throw new Error(`Invalid color: ${color}`); - } else if (Array.isArray(color)) { - return chalk.rgb(color[0], color[1], color[2]); + if (typeof color === "string") { + if (color in chalk) { + return (chalk as any)[color]; + } else if (color.startsWith("#") || color.match(/^#[0-9a-fA-F]{6}$/)) { + return chalk.hex(color); } - return chalk.ansi256(color); + throw new Error(`Invalid color: ${color}`); + } else if (Array.isArray(color)) { + return chalk.rgb(color[0], color[1], color[2]); + } + return chalk.ansi256(color); } diff --git a/src/test/subprocess.ts b/src/test/subprocess.ts index a5c4de4..95276be 100644 --- a/src/test/subprocess.ts +++ b/src/test/subprocess.ts @@ -4,53 +4,53 @@ // First argument tells what to test const testType = process.argv[2]; -if (testType === 'basic-injection') { +if (testType === "basic-injection") { // Import and test basic injection - import('@paperclover/console/inject').then(() => { + import("@paperclover/console/inject").then(() => { // Check that console methods are replaced - console.log('INJECTED_LOG'); - console.info('INJECTED_INFO'); - console.warn('INJECTED_WARN'); - console.error('INJECTED_ERROR'); - console.debug('INJECTED_DEBUG'); - + console.log("INJECTED_LOG"); + console.info("INJECTED_INFO"); + console.warn("INJECTED_WARN"); + console.error("INJECTED_ERROR"); + console.debug("INJECTED_DEBUG"); + // Assert - console.assert(true, 'This should not appear'); - console.assert(false, 'INJECTED_ASSERT'); - + console.assert(true, "This should not appear"); + console.assert(false, "INJECTED_ASSERT"); + // Exit when done setTimeout(() => process.exit(0), 100); }); -} else if (testType === 'time-functions') { +} else if (testType === "time-functions") { // Import and test timing functions - import('@paperclover/console/inject').then(() => { - console.time('TIMER_LABEL'); - console.timeLog('TIMER_LABEL'); - console.timeEnd('TIMER_LABEL'); - + import("@paperclover/console/inject").then(() => { + console.time("TIMER_LABEL"); + console.timeLog("TIMER_LABEL"); + console.timeEnd("TIMER_LABEL"); + // Test invalid timer - console.timeEnd('NONEXISTENT_TIMER'); - + console.timeEnd("NONEXISTENT_TIMER"); + // Exit when done setTimeout(() => process.exit(0), 100); }); -} else if (testType === 'count-functions') { +} else if (testType === "count-functions") { // Import and test count functions - import('@paperclover/console/inject').then(() => { - console.count('COUNTER_LABEL'); - console.count('COUNTER_LABEL'); - console.countReset('COUNTER_LABEL'); - console.count('COUNTER_LABEL'); - + import("@paperclover/console/inject").then(() => { + console.count("COUNTER_LABEL"); + console.count("COUNTER_LABEL"); + console.countReset("COUNTER_LABEL"); + console.count("COUNTER_LABEL"); + // Exit when done setTimeout(() => process.exit(0), 100); }); -} else if (testType === 'disabled-injection') { +} else if (testType === "disabled-injection") { // NO_COLOR should be passed via spawn options - import('@paperclover/console/inject').then(() => { - console.log('DISABLED_COLOR_LOG'); - + import("@paperclover/console/inject").then(() => { + console.log("DISABLED_COLOR_LOG"); + // Exit when done setTimeout(() => process.exit(0), 100); }); -} \ No newline at end of file +} diff --git a/src/test/widget-mock.ts b/src/test/widget-mock.ts index 57b189a..dc85461 100644 --- a/src/test/widget-mock.ts +++ b/src/test/widget-mock.ts @@ -1,103 +1,103 @@ //! Mock `@paperclover/console/Widget` to make testing easier. -import { mock, expect } from "bun:test"; +import { expect, mock } from "bun:test"; import assert from "node:assert"; import type { Widget } from "../Widget.ts"; -process.env.CI = 'true'; -process.env.FORCE_COLOR = '10'; +process.env.CI = "true"; +process.env.FORCE_COLOR = "10"; -let buffer = ''; +let buffer = ""; let widgets: MockWidget[] = []; globalThis.setTimeout = (() => { - throw new Error('Do not call setTimeout in tests'); + throw new Error("Do not call setTimeout in tests"); }) as any; globalThis.clearTimeout = (() => { - throw new Error('Do not call clearTimeout in tests'); + throw new Error("Do not call clearTimeout in tests"); }) as any; abstract class MockWidget { - abstract format(now: number): string; - abstract fps: number; + abstract format(now: number): string; + abstract fps: number; - isActive = true; - redrawCount = 0; - successCalls: string[] = []; - errorCalls: (string | Error)[] = []; - stopCalls: (string | undefined)[] = []; + isActive = true; + redrawCount = 0; + successCalls: string[] = []; + errorCalls: (string | Error)[] = []; + stopCalls: (string | undefined)[] = []; - constructor() { - } + constructor() { + } - stop(finalMessage?: string): void { - this.stopCalls.push(finalMessage); - this.isActive = false; - } + stop(finalMessage?: string): void { + this.stopCalls.push(finalMessage); + this.isActive = false; + } - protected redraw(): void { - this.redrawCount++; - } + protected redraw(): void { + this.redrawCount++; + } - success(message: string): void { - this.successCalls.push(message); - this.isActive = false; - } + success(message: string): void { + this.successCalls.push(message); + this.isActive = false; + } - error(message: string | Error): void { - this.errorCalls.push(message); - this.isActive = false; - } + error(message: string | Error): void { + this.errorCalls.push(message); + this.isActive = false; + } - get active(): boolean { - return this.isActive; - } + get active(): boolean { + return this.isActive; + } } const warn = console.warn; -mock.module(require.resolve('../Widget.ts'), () => ({ - writeLine: (line: string) => buffer += line + '\n', - errorAllWidgets: () => { - warn("errorAllWidgets is not implemented"); - }, - Widget: MockWidget, +mock.module(require.resolve("../Widget.ts"), () => ({ + writeLine: (line: string) => buffer += line + "\n", + errorAllWidgets: () => { + warn("errorAllWidgets is not implemented"); + }, + Widget: MockWidget, })); -mock.module('node:fs', () => ({ - writeSync: (fd: number, data: string) => { - assert(fd === 2, 'writeSync must be called with stderr'); - buffer += data; - }, +mock.module("node:fs", () => ({ + writeSync: (fd: number, data: string) => { + assert(fd === 2, "writeSync must be called with stderr"); + buffer += data; + }, })); export function reset() { - buffer = ''; - widgets = []; + buffer = ""; + widgets = []; } export function getBuffer() { - return buffer; + return buffer; } export function getWidgets() { - return widgets as any; + return widgets as any; } export function expectCalls(widget: Widget, checks: { - successCalls?: string[]; - errorCalls?: (string | Error)[]; - redraws?: number; - stopCalls?: (string | undefined)[]; + successCalls?: string[]; + errorCalls?: (string | Error)[]; + redraws?: number; + stopCalls?: (string | undefined)[]; }) { - const mockWidget = widget as unknown as MockWidget; - expect({ - successCalls: mockWidget.successCalls, - errorCalls: mockWidget.errorCalls, - redraws: mockWidget.redrawCount, - stopCalls: mockWidget.stopCalls, - }).toEqual({ - successCalls: checks.successCalls ?? [], - errorCalls: checks.errorCalls ?? [], - redraws: checks.redraws ?? 0, - stopCalls: checks.stopCalls ?? [], - }); -} \ No newline at end of file + const mockWidget = widget as unknown as MockWidget; + expect({ + successCalls: mockWidget.successCalls, + errorCalls: mockWidget.errorCalls, + redraws: mockWidget.redrawCount, + stopCalls: mockWidget.stopCalls, + }).toEqual({ + successCalls: checks.successCalls ?? [], + errorCalls: checks.errorCalls ?? [], + redraws: checks.redraws ?? 0, + stopCalls: checks.stopCalls ?? [], + }); +}