fix JSR requirements

This commit is contained in:
chloe caruso 2025-06-26 23:43:54 -07:00
parent acdad1b233
commit 1a6ac2b79f
19 changed files with 1257 additions and 1081 deletions

12
deno.jsonc Normal file
View file

@ -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"
}
}

View file

@ -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!');
const custom = console.scoped("my_own");
custom("Hello, world!");

View file

@ -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");

View file

@ -1,11 +1,11 @@
import { withProgress } from "@paperclover/console/Progress";
await withProgress('do a task', async (progress) => {
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));
await new Promise((resolve) => setTimeout(resolve, 10));
progress.value += 1;
}
});

View file

@ -1,32 +1,32 @@
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',
color: "blueBright",
});
second.fps = 30;
const third = new Spinner<{ value: string }>({
text: ({ value }) => `Spinner 3: ${value}`,
color: 'blueBright',
color: "blueBright",
});
third.fps = 4;
for (let i = 0; i < 40; i++) {
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
first.text = `Spinner 1: ${random()}`;
if (i === 20) {
second.success('second done!');
second.success("second done!");
}
third.update({ value: random() });
}
first.success('first done!');
first.success("first done!");
// third.success('third done!');
function random() {

View file

@ -1,8 +1,11 @@
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<T extends Record<string, unknown> = Record<never, unknown>> = import("@paperclover/console/Progress").Progress<T>;
const { Progress, withProgress } = await import(
"@paperclover/console/Progress"
);
type Progress<T extends Record<string, unknown> = Record<never, unknown>> =
import("@paperclover/console/Progress").Progress<T>;
beforeEach(reset);
@ -50,7 +53,7 @@ test("custom options apply correctly", () => {
barWidth: 20,
barStyle: "ascii",
value: 25,
total: 200
total: 200,
});
expect(progress.text).toEqual("Custom Progress");
@ -73,8 +76,8 @@ test("spinner options", () => {
spinner: {
frames: ["A", "B", "C"],
fps: 10,
color: "red"
}
color: "red",
},
});
expect(customSpinnerProgress.fps).toBeGreaterThan(0); // Should have fps when spinner is enabled
@ -84,7 +87,7 @@ test("spinner options", () => {
// Test with spinner disabled
const noSpinnerProgress = new Progress({
text: "No spinner",
spinner: null
spinner: null,
});
expect(noSpinnerProgress.fps).toBe(0); // Should have 0 fps when spinner is disabled
@ -102,14 +105,14 @@ test("text getter and setter", () => {
expect(progress.text).toEqual("Updated text");
expectCalls(progress, {
redraws: 1
redraws: 1,
});
});
test("beforeText getter and setter", () => {
const progress = new Progress({
text: "Main text",
beforeText: "Initial before"
beforeText: "Initial before",
});
expect(progress.beforeText).toEqual("Initial before");
@ -117,7 +120,7 @@ test("beforeText getter and setter", () => {
expect(progress.beforeText).toEqual("Updated before");
expectCalls(progress, {
redraws: 1
redraws: 1,
});
});
@ -129,25 +132,34 @@ test("value and total getters and setters", () => {
progress.value = 25;
expect(progress.value).toEqual(25);
expectCalls(progress, {
redraws: 1
redraws: 1,
});
progress.total = 200;
expect(progress.total).toEqual(200);
expectCalls(progress, {
redraws: 2
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
total: 120,
});
// Text function should use props and calculate progress
@ -162,14 +174,14 @@ test("function for text field and props", () => {
expect(progress.text).toEqual("Downloading: 60/120 (50%)");
expectCalls(progress, {
redraws: 2
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" }
props: { status: "Loading" },
});
// Initial state
@ -186,7 +198,7 @@ test("update method and its overloads", () => {
expect(progress.text).toEqual("Downloading: 75/100");
expectCalls(progress, {
redraws: 2
redraws: 2,
});
});
@ -196,28 +208,28 @@ test("success and error methods", () => {
// Test success with default message
progress.success();
expectCalls(progress, {
successCalls: ["Working"]
successCalls: ["Working"],
});
// Test success with custom message
const progress2 = new Progress("Working");
progress2.success("Completed");
expectCalls(progress2, {
successCalls: ["Completed"]
successCalls: ["Completed"],
});
// Test error with default message
const progress3 = new Progress("Working");
progress3.error();
expectCalls(progress3, {
errorCalls: ["Working"]
errorCalls: ["Working"],
});
// Test error with custom message
const progress4 = new Progress("Working");
progress4.error("Failed");
expectCalls(progress4, {
errorCalls: ["Failed"]
errorCalls: ["Failed"],
});
// Test error with Error object
@ -225,7 +237,7 @@ test("success and error methods", () => {
const progress5 = new Progress("Working");
progress5.error(error);
expectCalls(progress5, {
errorCalls: [error]
errorCalls: [error],
});
});
@ -247,7 +259,7 @@ test("withProgress function", async () => {
} finally {
expectCalls(bar, {
successCalls: ["Completed"],
redraws: 1
redraws: 1,
});
}
});
@ -258,7 +270,7 @@ test("withProgress function with custom success text", async () => {
try {
await withProgress({
text: "Processing",
successText: "Task completed successfully"
successText: "Task completed successfully",
}, async (progress) => {
bar = progress;
return "data";
@ -266,7 +278,7 @@ test("withProgress function with custom success text", async () => {
} finally {
expectCalls(bar, {
successCalls: ["Task completed successfully"],
redraws: 0
redraws: 0,
});
}
});
@ -278,7 +290,8 @@ test("withProgress function with success text function", async () => {
try {
await withProgress({
text: "Processing",
successText: (result: { count: number }) => `Processed ${result.count} items`
successText: (result: { count: number }) =>
`Processed ${result.count} items`,
}, async (progress) => {
bar = progress;
return data;
@ -286,7 +299,7 @@ test("withProgress function with success text function", async () => {
} finally {
expectCalls(bar, {
successCalls: ["Processed 42 items"],
redraws: 0
redraws: 0,
});
}
});
@ -298,7 +311,7 @@ test("withProgress function with error", async () => {
try {
await withProgress({
text: "Processing",
failureText: "Task failed"
failureText: "Task failed",
}, async (progress) => {
bar = progress;
throw error;
@ -308,7 +321,7 @@ test("withProgress function with error", async () => {
expect(e).toEqual(error);
expectCalls(bar, {
errorCalls: ["Task failed"],
redraws: 0
redraws: 0,
});
}
});
@ -320,7 +333,7 @@ test("withProgress function with error text function", async () => {
try {
await withProgress({
text: "Processing",
failureText: (err) => `Error: ${err.message}`
failureText: (err) => `Error: ${err.message}`,
}, async (progress) => {
bar = progress;
throw error;
@ -330,7 +343,7 @@ test("withProgress function with error text function", async () => {
expect(e).toEqual(error);
expectCalls(bar, {
errorCalls: ["Error: Process failed with code 500"],
redraws: 0
redraws: 0,
});
}
});

View file

@ -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<Props extends Record<string, unknown> = Record<never, unknown>> {
export interface ProgressOptions<
Props extends Record<string, unknown> = Record<never, unknown>,
> {
/** Text displayed to the right of the bar. */
text: string | ((props: ExtendedProps<Props>) => 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> = T & {
progress: number;
};
export class Progress<Props extends Record<string, unknown> = Record<never, unknown>> extends Widget {
export class Progress<
Props extends Record<string, unknown> = Record<never, unknown>,
> extends Widget {
#text: string | ((props: ExtendedProps<Props>) => string);
#beforeText: string | ((props: ExtendedProps<Props>) => string);
#barWidth: number;
#barStyle: NonNullable<ProgressOptions['barStyle']>;
#spinnerColor: NonNullable<BarSpinnerOptions['color']>;
#barStyle: NonNullable<ProgressOptions["barStyle"]>;
#spinnerColor: NonNullable<BarSpinnerOptions["color"]>;
#spinnerFrames?: readonly string[];
#props: Props;
#spinnerFPS: number;
@ -121,7 +129,7 @@ export class Progress<Props extends Record<string, unknown> = Record<never, unkn
constructor(options: ProgressOptions<Props> | string) {
super();
if (typeof options === 'string') {
if (typeof options === "string") {
options = { text: options };
}
@ -138,8 +146,10 @@ export class Progress<Props extends Record<string, unknown> = Record<never, unkn
if (options.spinner !== null) {
this.fps = 15;
this.#spinnerFPS = options.spinner?.fps ?? defaultOptions.spinner.fps;
this.#spinnerFrames = options.spinner?.frames ?? defaultOptions.spinner.frames;
this.#spinnerColor = options.spinner?.color ?? defaultOptions.spinner.color;
this.#spinnerFrames = options.spinner?.frames ??
defaultOptions.spinner.frames;
this.#spinnerColor = options.spinner?.color ??
defaultOptions.spinner.color;
} else {
this.fps = 0;
this.#spinnerFPS = defaultOptions.spinner.fps;
@ -168,7 +178,9 @@ export class Progress<Props extends Record<string, unknown> = Record<never, unkn
/** Text displayed to the right of the bar. */
get text(): string {
return typeof this.#text === 'function' ? this.#text(this.props) : this.#text;
return typeof this.#text === "function"
? this.#text(this.props)
: this.#text;
}
set text(value: string | (() => string)) {
@ -178,7 +190,9 @@ export class Progress<Props extends Record<string, unknown> = Record<never, unkn
/** Text displayed to the left of the bar, if specified. */
get beforeText(): string {
return typeof this.#beforeText === 'function' ? this.#beforeText(this.props) : this.#beforeText;
return typeof this.#beforeText === "function"
? this.#beforeText(this.props)
: this.#beforeText;
}
set beforeText(value: string | (() => string)) {
@ -186,7 +200,7 @@ export class Progress<Props extends Record<string, unknown> = Record<never, unkn
this.redraw();
}
/** Current value of progress bar. */
get value() {
get value(): number {
return this.#value;
}
@ -196,7 +210,7 @@ export class Progress<Props extends Record<string, unknown> = Record<never, unkn
}
/** Total value of progress bar. When value === total, the bar is full. */
get total() {
get total(): number {
return this.#total;
}
@ -227,34 +241,37 @@ export class Progress<Props extends Record<string, unknown> = Record<never, unkn
let spinner;
if (this.#spinnerFrames) {
const frame = Math.floor(now / (1000 / this.#spinnerFPS)) % this.#spinnerFrames.length;
const frame = Math.floor(now / (1000 / this.#spinnerFPS)) %
this.#spinnerFrames.length;
spinner = this.#spinnerColor
? (this.#spinnerColor === 'match'
? (this.#spinnerColor === "match"
? chalk.rgb(...convertHSVtoRGB(hue, 0.8, 1))
: getColor(this.#spinnerColor))(this.#spinnerFrames[frame])
: this.#spinnerFrames[frame];
}
const getBar = isUnicodeSupported && this.#barStyle === 'unicode' ? getUnicodeBar : getAsciiBar;
const getBar = isUnicodeSupported && this.#barStyle === "unicode"
? getUnicodeBar
: getAsciiBar;
const beforeText = this.beforeText;
return [
spinner ? spinner + ' ' : '',
beforeText ? beforeText + ' ' : '',
spinner ? spinner + " " : "",
beforeText ? beforeText + " " : "",
barColor(getBar(progress, this.#barWidth)),
' ',
" ",
this.text,
]
.filter(Boolean)
.join('');
.join("");
}
success(message?: string): void {
override success(message?: string): void {
super.success(message ?? this.text);
}
error(message?: string | Error): void {
override error(message?: string | Error): void {
super.error(message ?? this.text);
}
}
@ -270,7 +287,7 @@ export interface WithProgressOptions<Props extends Record<string, unknown>, T>
/** Calls a function with a progress bar. */
export async function withProgress<Props extends Record<string, unknown>, T>(
opts: WithProgressOptions<Props, T> | string,
fn: (bar: Progress<Props>) => Promise<T>
fn: (bar: Progress<Props>) => Promise<T>,
): Promise<T>;
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;
}

View file

@ -1,8 +1,11 @@
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<T extends Record<string, unknown> = Record<string, unknown>> = import("@paperclover/console/Spinner").Spinner<T>;
const { Spinner, defaultSpinnerOptions, withSpinner } = await import(
"@paperclover/console/Spinner"
);
type Spinner<T extends Record<string, unknown> = Record<string, unknown>> =
import("@paperclover/console/Spinner").Spinner<T>;
beforeEach(reset);
@ -15,7 +18,9 @@ test("default options and rendering", () => {
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`);
expect(spinner.format(now)).toEqual(
`\x1b[94m${spinner.frames[i]}\x1b[39m spin`,
);
now += 1000 / spinner.fps;
}
@ -27,13 +32,13 @@ test("default options and rendering", () => {
});
test("custom options apply correctly", () => {
const customFrames = ['A', 'B', 'C'];
const customFrames = ["A", "B", "C"];
const customFps = 24;
const spinner = new Spinner({
text: "custom spinner",
frames: customFrames,
fps: customFps
fps: customFps,
});
expect(spinner.frames).toEqual(customFrames);
@ -42,7 +47,9 @@ test("custom options apply correctly", () => {
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`);
expect(spinner.format(now)).toEqual(
`\x1b[94m${spinner.frames[i]}\x1b[39m custom spinner`,
);
now += 1000 / spinner.fps;
}
});
@ -51,14 +58,16 @@ test("custom color options", () => {
// Test with named color
const redSpinner = new Spinner({
text: "red spinner",
color: "red"
color: "red",
});
expect(redSpinner.format(0)).toEqual(`\x1b[31m${redSpinner.frames[0]}\x1b[39m red spinner`);
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"
color: "#ff00ff",
});
expect(hexSpinner.format(0)).toContain(hexSpinner.frames[0]);
expect(hexSpinner.format(0)).toContain("hex spinner");
@ -66,7 +75,7 @@ test("custom color options", () => {
// Test with rgb array
const rgbSpinner = new Spinner({
text: "rgb spinner",
color: [255, 0, 255]
color: [255, 0, 255],
});
expect(rgbSpinner.format(0)).toContain(rgbSpinner.frames[0]);
expect(rgbSpinner.format(0)).toContain("rgb spinner");
@ -74,16 +83,18 @@ test("custom color options", () => {
// Test with color disabled
const noColorSpinner = new Spinner({
text: "no color",
color: false
color: false,
});
expect(noColorSpinner.format(0)).toEqual(`${noColorSpinner.frames[0]} no color`);
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
color: "invalidColor" as any,
});
}).toThrow("Invalid color: invalidColor");
});
@ -96,7 +107,7 @@ test("text getter and setter", () => {
expect(spinner.text).toEqual("updated text");
expectCalls(spinner, {
redraws: 1
redraws: 1,
});
});
@ -104,7 +115,7 @@ 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 }
props: { count: 5 },
});
expect(spinner.text).toEqual("Items: 5");
@ -115,7 +126,7 @@ test("function for text field and props getter and setter", () => {
expect(spinner.props).toEqual({ count: 10 });
expectCalls(spinner, {
redraws: 1
redraws: 1,
});
// Adding new props should keep existing ones
@ -123,7 +134,7 @@ test("function for text field and props getter and setter", () => {
expect(spinner.props).toEqual({ count: 10, newProp: "value" });
expectCalls(spinner, {
redraws: 2
redraws: 2,
});
});
@ -134,17 +145,17 @@ test("update method with string", () => {
expect(spinner.text).toEqual("updated via update");
expectCalls(spinner, {
redraws: 1
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 }>({
const spinner = new Spinner<{ count: number; name?: string }>({
text: textFn,
props: { count: 0 }
props: { count: 0 },
});
expect(spinner.text).toEqual("Items: 0");
@ -156,7 +167,7 @@ test("update method with props", () => {
expect(spinner.text).toEqual("Products: 5");
expectCalls(spinner, {
redraws: 2
redraws: 2,
});
});
@ -165,13 +176,13 @@ test("success method", () => {
spinner.success();
expectCalls(spinner, {
successCalls: ["working"]
successCalls: ["working"],
});
const spinner2 = new Spinner("working");
spinner2.success("completed");
expectCalls(spinner2, {
successCalls: ["completed"]
successCalls: ["completed"],
});
});
@ -180,20 +191,20 @@ test("error method", () => {
spinner.error();
expectCalls(spinner, {
errorCalls: ["working"]
errorCalls: ["working"],
});
const spinner2 = new Spinner("working");
spinner2.error("failed");
expectCalls(spinner2, {
errorCalls: ["failed"]
errorCalls: ["failed"],
});
const error = new Error("Something went wrong");
const spinner3 = new Spinner("working");
spinner3.error(error);
expectCalls(spinner3, {
errorCalls: [error]
errorCalls: [error],
});
});
@ -217,7 +228,7 @@ test("withSpinner function with custom success text", async () => {
let spin!: Spinner;
const result = await withSpinner({
text: "Loading",
successText: "Data loaded successfully"
successText: "Data loaded successfully",
}, async (spinner) => {
spin = spinner;
return "data";
@ -233,7 +244,7 @@ test("withSpinner function with success text function", async () => {
let spin!: Spinner;
const result = await withSpinner({
text: "Loading",
successText: (data: number[]) => `Loaded ${data.length} items`
successText: (data: number[]) => `Loaded ${data.length} items`,
}, async (spinner) => {
spin = spinner;
return [1, 2, 3];
@ -252,7 +263,7 @@ test("withSpinner function with error", async () => {
try {
await withSpinner({
text: "Loading",
failureText: "Could not load data"
failureText: "Could not load data",
}, async (spinner) => {
spin = spinner;
throw error;
@ -261,7 +272,7 @@ test("withSpinner function with error", async () => {
} catch (e) {
expect(e).toEqual(error);
expectCalls(spin, {
errorCalls: ["Could not load data"]
errorCalls: ["Could not load data"],
});
}
});
@ -273,7 +284,7 @@ test("withSpinner function with error text function", async () => {
try {
await withSpinner({
text: "Loading",
failureText: (err) => `Error: ${err.message}`
failureText: (err) => `Error: ${err.message}`,
}, async (spinner) => {
spin = spinner;
throw error;
@ -282,7 +293,7 @@ test("withSpinner function with error text function", async () => {
} catch (e) {
expect(e).toEqual(error);
expectCalls(spin, {
errorCalls: ["Error: Network error"]
errorCalls: ["Error: Network error"],
});
}
});

View file

@ -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<Props extends Record<string, unknown>> {
/** Text displayed to the right of the spinner. */
@ -15,13 +15,15 @@ export interface SpinnerOptions<Props extends Record<string, unknown>> {
}
export const defaultSpinnerOptions = {
text: 'Loading...',
color: 'blueBright',
frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
text: "Loading...",
color: "blueBright",
frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
fps: 12.5,
} as const;
export class Spinner<Props extends Record<string, unknown> = Record<string, unknown>> extends Widget {
export class Spinner<
Props extends Record<string, unknown> = Record<string, unknown>,
> extends Widget {
readonly frames: readonly string[];
#text: string | ((props: Props) => string);
#color: ((text: string) => string) | null;
@ -30,7 +32,7 @@ export class Spinner<Props extends Record<string, unknown> = Record<string, unkn
constructor(options: SpinnerOptions<Props> | 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<Props extends Record<string, unknown> = Record<string, unkn
/** Text displayed to the right of the spinner. */
get text(): string {
return typeof this.#text === 'function' ? this.#text(this.#props) : this.#text;
return typeof this.#text === "function"
? this.#text(this.#props)
: this.#text;
}
set text(value: string | (() => string)) {
@ -73,7 +77,7 @@ export class Spinner<Props extends Record<string, unknown> = Record<string, unkn
update(newProps: Partial<Props>): void;
update(newMessage: string): void;
update(newData: string | Partial<Props>) {
if (typeof newData === 'string') {
if (typeof newData === "string") {
this.text = newData;
} else {
this.#props = { ...this.#props, ...newData };
@ -84,18 +88,14 @@ export class Spinner<Props extends Record<string, unknown> = Record<string, unkn
format(now: number): string {
const frame = Math.floor(now / (1000 / this.fps)) % this.frames.length;
const frameText = this.frames[frame]!;
return (
(this.#color ? this.#color(frameText) : frameText) +
' ' +
this.text
);
return (this.#color ? this.#color(frameText) : frameText) + " " + this.text;
}
success(message?: string): void {
override success(message?: string): void {
super.success(message ?? this.text);
}
error(message?: string | Error): void {
override error(message?: string | Error): void {
super.error(message ?? this.text);
}
}
@ -109,7 +109,7 @@ export interface WithSpinnerOptions<Props extends Record<string, unknown>, T>
/** Calls a function with a spinner. */
export async function withSpinner<Props extends Record<string, unknown>, T>(
spinnerOptions: WithSpinnerOptions<Props, T> | string,
fn: (spinner: Spinner<Props>) => Promise<T>
fn: (spinner: Spinner<Props>) => Promise<T>,
): Promise<T>;
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'
? typeof opts.text === "function"
? opts.text(spinner.props)
: opts.text
: 'Completed'
: "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;
}

View file

@ -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<typeof setInterval> & { 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();
}

View file

@ -9,7 +9,9 @@ test("built-in log levels", () => {
expect(getBuffer()).toEqual(`${chalk.blueBright.bold("info")} log\n`);
reset();
console.warn("log");
expect(getBuffer()).toEqual(`${chalk.yellowBright.bold("warn")} ${chalk.yellowBright("log")}\n`);
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

View file

@ -1,35 +1,34 @@
export const isUnicodeSupported =
process.platform === 'win32'
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';
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<string | string[]>) {
filters = newFilters.flat().map(filter => filter.toLowerCase());
filters = newFilters.flat().map((filter) => filter.toLowerCase());
filterGeneration++;
}
export function isLogVisible(id: string, defaultVisibility = true) {
export function isLogVisible(id: string, defaultVisibility = true): boolean {
for (const filter of filters) {
if (filter === '*') {
if (filter === "*") {
defaultVisibility = true;
} else if (filter === '-*') {
} else if (filter === "-*") {
defaultVisibility = false;
} else if (filter === id || id.startsWith(filter + ':')) {
} else if (filter === id || id.startsWith(filter + ":")) {
defaultVisibility = true;
} else if (filter === '-' + id || id.startsWith('-' + filter + ':')) {
} else if (filter === "-" + id || id.startsWith("-" + filter + ":")) {
defaultVisibility = false;
}
}
@ -39,33 +38,104 @@ export function isLogVisible(id: string, defaultVisibility = true) {
if (process.env.DEBUG !== undefined) {
setLogFilter(
String(process.env.DEBUG)
.split(',')
.map(x => x.trim())
.map(x => (['1', 'true', 'all'].includes(x.toLowerCase()) ? '*' : x))
.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') {
.map((obj) => {
if (typeof obj === "string") {
return obj;
} else if (obj instanceof Error) {
return formatErrorObj(obj);
}
return inspect(obj, false, 4, true);
})
.join(' ');
.join(" ");
}
/**
@ -84,34 +154,32 @@ function selectColor(namespace: string) {
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') {
if (typeof fmt === "string") {
let index = 0;
const result = fmt.replace(/%[%sdifoxXcj]/g, match => {
if (match === '%%') {
return '%';
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 + " " + stringify(...args);
}
return result;
@ -119,10 +187,11 @@ function format(fmt: any, ...args: any[]) {
return stringify(fmt, ...args);
}
const LogFunction = {
/** @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');
[Symbol.for("nodejs.util.inspect.custom")](depth: number, options: any) {
return options.stylize(`[LogFunction: ${(this as any).name}]`, "special");
},
};
@ -139,9 +208,13 @@ const LogFunction = {
*/
export function scoped(
name: string,
opts: CustomLoggerOptions | CustomLoggerColor = {}
opts: CustomLoggerOptions | CustomLoggerColor = {},
): LogFunction {
if (typeof opts === 'string' || Array.isArray(opts) || typeof opts === 'number') {
if (
typeof opts === "string" ||
Array.isArray(opts) ||
typeof opts === "number"
) {
opts = { color: opts };
}
const {
@ -152,7 +225,7 @@ export function scoped(
debug = false,
} = opts;
const strippedName = stripAnsi(name);
const colorFn = name.includes('\x1b')
const colorFn = name.includes("\x1b")
? chalk
: color
? getColor(color)
@ -163,83 +236,95 @@ export function scoped(
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)
coloredName +
" " +
(coloredText ? boldText ? colorFn.bold(data) : colorFn(data) : data),
);
}
}) as LogFunction;
Object.setPrototypeOf(fn, LogFunction);
Object.defineProperty(fn, 'name', { value: id });
Object.defineProperty(fn, "name", { value: id });
let gen = filterGeneration;
let visible = isLogVisible(id, !debug);
Object.defineProperty(fn, 'visible', {
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',
export const warn: LogFunction = /* @__PURE__ */ scoped("warn", {
color: "yellowBright",
coloredText: true,
});
const _trace = /* @__PURE__ */ scoped('trace', {
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[]) {
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'));
_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',
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',
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',
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";

View file

@ -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", () => {
@ -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 ");
});

View file

@ -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: '<top level>',
method: "<top level>",
file: match2[1],
line: match2[2],
column: match2[3],
};
}
return { method: '<unknown>', file: null, line: null, column: null };
return {
method: "<unknown>",
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)})`
: [
'(',
? isBuiltin(file) ? `(${chalk.magenta(file)})` : [
"(",
getColoredDirname(file),
// Leave the first slash on linux.
chalk.green(path.basename(file)),
':',
line + ':' + column,
')',
].join('')
: '<unknown>';
":",
line + ":" + column,
")",
].join("")
: "<unknown>";
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;
}
}

View file

@ -1,16 +1,19 @@
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<string, string> = {}): Promise<string> {
function runSubprocess(
testType: string,
env: Record<string, string> = {},
): Promise<string> {
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 = "";
@ -42,7 +45,7 @@ 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", {
DEBUG: "1",
FORCE_COLOR: "1"
FORCE_COLOR: "1",
});
// Check that console methods are correctly injected

View file

@ -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<string, number>();
@ -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);
});

View file

@ -1,7 +1,11 @@
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] {
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;
@ -35,10 +39,12 @@ export function convertHSVtoRGB(h: number, s: number, v: number): [number, numbe
/** 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;
@ -52,39 +58,39 @@ const stderr = process.stderr;
export function flushStderr() {
if (buffer) {
if (bufferNeedsUnfreeze) {
buffer += '\u001B[?2026l';
buffer += "\u001B[?2026l";
bufferNeedsUnfreeze = false;
}
stderr.write(buffer);
buffer = '';
buffer = "";
}
}
process.on('exit', () => {
process.on("exit", () => {
exiting = true;
buffer += '\x1b[0;39;49m';
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'
| "black"
| "red"
| "green"
| "yellow"
| "blue"
| "magenta"
| "cyan"
| "white"
| "gray"
| "grey"
| "blackBright"
| "redBright"
| "greenBright"
| "yellowBright"
| "blueBright"
| "magentaBright"
| "cyanBright"
| "whiteBright"
| `#${string}`
| number
| [number, number, number];
@ -104,16 +110,16 @@ export interface StringLike {
}
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> = S extends `${string}%${infer K}${infer B}`
@ -150,10 +156,10 @@ export interface LogFunction {
}
export function getColor(color: CustomLoggerColor): ChalkInstance {
if (typeof color === 'string') {
if (typeof color === "string") {
if (color in chalk) {
return (chalk as any)[color];
} else if (color.startsWith('#') || color.match(/^#[0-9a-fA-F]{6}$/)) {
} else if (color.startsWith("#") || color.match(/^#[0-9a-fA-F]{6}$/)) {
return chalk.hex(color);
}
throw new Error(`Invalid color: ${color}`);

View file

@ -4,51 +4,51 @@
// 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);

View file

@ -1,19 +1,19 @@
//! 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 {
@ -55,22 +55,22 @@ abstract class MockWidget {
const warn = console.warn;
mock.module(require.resolve('../Widget.ts'), () => ({
writeLine: (line: string) => buffer += line + '\n',
mock.module(require.resolve("../Widget.ts"), () => ({
writeLine: (line: string) => buffer += line + "\n",
errorAllWidgets: () => {
warn("errorAllWidgets is not implemented");
},
Widget: MockWidget,
}));
mock.module('node:fs', () => ({
mock.module("node:fs", () => ({
writeSync: (fd: number, data: string) => {
assert(fd === 2, 'writeSync must be called with stderr');
assert(fd === 2, "writeSync must be called with stderr");
buffer += data;
},
}));
export function reset() {
buffer = '';
buffer = "";
widgets = [];
}