fix JSR requirements
This commit is contained in:
parent
acdad1b233
commit
1a6ac2b79f
19 changed files with 1257 additions and 1081 deletions
12
deno.jsonc
Normal file
12
deno.jsonc
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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!");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
113
src/Progress.ts
113
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<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;
|
||||
}
|
||||
|
|
|
@ -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"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
247
src/console.ts
247
src/console.ts
|
@ -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";
|
||||
|
|
|
@ -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 ");
|
||||
});
|
||||
|
|
107
src/error.ts
107
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: '<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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -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}`);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 = [];
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue