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) => {
// mutate the progress object
progress.total = 100;
await withProgress("do a task", async (progress) => {
// mutate the progress object
progress.total = 100;
for (let i = 0; i < progress.total; i++) {
await new Promise(resolve => setTimeout(resolve, 10));
progress.value += 1;
}
for (let i = 0; i < progress.total; i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
progress.value += 1;
}
});

View file

@ -1,34 +1,34 @@
import { Spinner } from "@paperclover/console/Spinner";
const first = new Spinner({
text: 'Spinner 1: ',
color: 'blueBright',
text: "Spinner 1: ",
color: "blueBright",
});
const second = new Spinner({
text: () => `Spinner 2: ${random()}`,
color: 'blueBright',
text: () => `Spinner 2: ${random()}`,
color: "blueBright",
});
second.fps = 30;
const third = new Spinner<{ value: string }>({
text: ({ value }) => `Spinner 3: ${value}`,
color: 'blueBright',
text: ({ value }) => `Spinner 3: ${value}`,
color: "blueBright",
});
third.fps = 4;
for (let i = 0; i < 40; i++) {
await new Promise(resolve => setTimeout(resolve, 100));
first.text = `Spinner 1: ${random()}`;
if (i === 20) {
second.success('second done!');
}
third.update({ value: random() });
await new Promise((resolve) => setTimeout(resolve, 100));
first.text = `Spinner 1: ${random()}`;
if (i === 20) {
second.success("second done!");
}
third.update({ value: random() });
}
first.success('first done!');
first.success("first done!");
// third.success('third done!');
function random() {
return Math.random().toString(36).substring(2, 15);
}
return Math.random().toString(36).substring(2, 15);
}

View file

@ -1,336 +1,349 @@
import { beforeEach, expect, test, mock } from "bun:test";
import { reset, expectCalls, getWidgets } from "./test/widget-mock.ts";
import { beforeEach, expect, mock, test } from "bun:test";
import { expectCalls, getWidgets, reset } from "./test/widget-mock.ts";
const { Progress, withProgress } = await import("@paperclover/console/Progress");
type Progress<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);
test("default options and basic rendering", () => {
const progress = new Progress("Loading");
const progress = new Progress("Loading");
expect(progress.text).toEqual("Loading");
expect(progress.beforeText).toEqual("");
expect(progress.value).toEqual(0);
expect(progress.total).toEqual(100);
expect(progress.text).toEqual("Loading");
expect(progress.beforeText).toEqual("");
expect(progress.value).toEqual(0);
expect(progress.total).toEqual(100);
// Format should include a progress bar
const formatted = progress.format(0);
expect(formatted).toContain("Loading");
// The progress bar might not contain "█" at 0% progress,
// but it should contain color codes and spaces for the bar
expect(formatted).toContain("\u001b[38;2;"); // Color code
// Format should include a progress bar
const formatted = progress.format(0);
expect(formatted).toContain("Loading");
// The progress bar might not contain "█" at 0% progress,
// but it should contain color codes and spaces for the bar
expect(formatted).toContain("\u001b[38;2;"); // Color code
});
test("progress percentage displays correctly", () => {
const progress = new Progress("Loading");
const progress = new Progress("Loading");
// Test zero progress
progress.value = 0;
expect(progress.format(0)).toContain("Loading");
// Test zero progress
progress.value = 0;
expect(progress.format(0)).toContain("Loading");
// Test partial progress
progress.value = 50;
const halfProgress = progress.format(0);
expect(halfProgress).toContain("Loading");
// Test partial progress
progress.value = 50;
const halfProgress = progress.format(0);
expect(halfProgress).toContain("Loading");
// Test full progress
progress.value = 100;
const fullProgress = progress.format(0);
expect(fullProgress).toContain("Loading");
// Test full progress
progress.value = 100;
const fullProgress = progress.format(0);
expect(fullProgress).toContain("Loading");
// Should be visibly different
expect(progress.format(0)).not.toEqual(halfProgress);
// Should be visibly different
expect(progress.format(0)).not.toEqual(halfProgress);
});
test("custom options apply correctly", () => {
const progress = new Progress({
text: "Custom Progress",
beforeText: "Loading:",
barWidth: 20,
barStyle: "ascii",
value: 25,
total: 200
});
const progress = new Progress({
text: "Custom Progress",
beforeText: "Loading:",
barWidth: 20,
barStyle: "ascii",
value: 25,
total: 200,
});
expect(progress.text).toEqual("Custom Progress");
expect(progress.beforeText).toEqual("Loading:");
expect(progress.value).toEqual(25);
expect(progress.total).toEqual(200);
expect(progress.text).toEqual("Custom Progress");
expect(progress.beforeText).toEqual("Loading:");
expect(progress.value).toEqual(25);
expect(progress.total).toEqual(200);
const formatted = progress.format(0);
expect(formatted).toContain("Custom Progress");
expect(formatted).toContain("Loading:");
// With ascii style, it should contain these characters
expect(formatted).toContain("[");
expect(formatted).toContain("]");
const formatted = progress.format(0);
expect(formatted).toContain("Custom Progress");
expect(formatted).toContain("Loading:");
// With ascii style, it should contain these characters
expect(formatted).toContain("[");
expect(formatted).toContain("]");
});
test("spinner options", () => {
// Test with custom spinner
const customSpinnerProgress = new Progress({
text: "With spinner",
spinner: {
frames: ["A", "B", "C"],
fps: 10,
color: "red"
}
});
// Test with custom spinner
const customSpinnerProgress = new Progress({
text: "With spinner",
spinner: {
frames: ["A", "B", "C"],
fps: 10,
color: "red",
},
});
expect(customSpinnerProgress.fps).toBeGreaterThan(0); // Should have fps when spinner is enabled
const formatted = customSpinnerProgress.format(0);
expect(formatted).toContain("A"); // Should include the first spinner frame
expect(customSpinnerProgress.fps).toBeGreaterThan(0); // Should have fps when spinner is enabled
const formatted = customSpinnerProgress.format(0);
expect(formatted).toContain("A"); // Should include the first spinner frame
// Test with spinner disabled
const noSpinnerProgress = new Progress({
text: "No spinner",
spinner: null
});
// Test with spinner disabled
const noSpinnerProgress = new Progress({
text: "No spinner",
spinner: null,
});
expect(noSpinnerProgress.fps).toBe(0); // Should have 0 fps when spinner is disabled
const noSpinnerFormatted = noSpinnerProgress.format(0);
expect(noSpinnerFormatted).not.toContain("A");
expect(noSpinnerFormatted).not.toContain("B");
expect(noSpinnerFormatted).not.toContain("C");
expect(noSpinnerProgress.fps).toBe(0); // Should have 0 fps when spinner is disabled
const noSpinnerFormatted = noSpinnerProgress.format(0);
expect(noSpinnerFormatted).not.toContain("A");
expect(noSpinnerFormatted).not.toContain("B");
expect(noSpinnerFormatted).not.toContain("C");
});
test("text getter and setter", () => {
const progress = new Progress("Initial text");
expect(progress.text).toEqual("Initial text");
const progress = new Progress("Initial text");
expect(progress.text).toEqual("Initial text");
progress.text = "Updated text";
expect(progress.text).toEqual("Updated text");
progress.text = "Updated text";
expect(progress.text).toEqual("Updated text");
expectCalls(progress, {
redraws: 1
});
expectCalls(progress, {
redraws: 1,
});
});
test("beforeText getter and setter", () => {
const progress = new Progress({
text: "Main text",
beforeText: "Initial before"
});
expect(progress.beforeText).toEqual("Initial before");
const progress = new Progress({
text: "Main text",
beforeText: "Initial before",
});
expect(progress.beforeText).toEqual("Initial before");
progress.beforeText = "Updated before";
expect(progress.beforeText).toEqual("Updated before");
progress.beforeText = "Updated before";
expect(progress.beforeText).toEqual("Updated before");
expectCalls(progress, {
redraws: 1
});
expectCalls(progress, {
redraws: 1,
});
});
test("value and total getters and setters", () => {
const progress = new Progress("Progress");
expect(progress.value).toEqual(0);
expect(progress.total).toEqual(100);
const progress = new Progress("Progress");
expect(progress.value).toEqual(0);
expect(progress.total).toEqual(100);
progress.value = 25;
expect(progress.value).toEqual(25);
expectCalls(progress, {
redraws: 1
});
progress.value = 25;
expect(progress.value).toEqual(25);
expectCalls(progress, {
redraws: 1,
});
progress.total = 200;
expect(progress.total).toEqual(200);
expectCalls(progress, {
redraws: 2
});
progress.total = 200;
expect(progress.total).toEqual(200);
expectCalls(progress, {
redraws: 2,
});
});
test("function for text field and props", () => {
const textFn = (props: { value: number; total: number; progress: number; customProp: string }) =>
`${props.customProp}: ${props.value}/${props.total} (${Math.round(props.progress * 100)}%)`;
const textFn = (
props: {
value: number;
total: number;
progress: number;
customProp: string;
},
) =>
`${props.customProp}: ${props.value}/${props.total} (${
Math.round(props.progress * 100)
}%)`;
const progress = new Progress<{ customProp: string }>({
text: textFn,
props: { customProp: "Loading" },
value: 30,
total: 120
});
const progress = new Progress<{ customProp: string }>({
text: textFn,
props: { customProp: "Loading" },
value: 30,
total: 120,
});
// Text function should use props and calculate progress
expect(progress.text).toEqual("Loading: 30/120 (25%)");
// Text function should use props and calculate progress
expect(progress.text).toEqual("Loading: 30/120 (25%)");
// Updating props should reflect in text
progress.props = { customProp: "Downloading" };
expect(progress.text).toEqual("Downloading: 30/120 (25%)");
// Updating props should reflect in text
progress.props = { customProp: "Downloading" };
expect(progress.text).toEqual("Downloading: 30/120 (25%)");
// Updating value should reflect in text
progress.value = 60;
expect(progress.text).toEqual("Downloading: 60/120 (50%)");
// Updating value should reflect in text
progress.value = 60;
expect(progress.text).toEqual("Downloading: 60/120 (50%)");
expectCalls(progress, {
redraws: 2
});
expectCalls(progress, {
redraws: 2,
});
});
test("update method and its overloads", () => {
const progress = new Progress<{ status: string }>({
text: (props) => `${props.status}: ${props.value}/${props.total}`,
props: { status: "Loading" }
});
const progress = new Progress<{ status: string }>({
text: (props) => `${props.status}: ${props.value}/${props.total}`,
props: { status: "Loading" },
});
// Initial state
expect(progress.text).toEqual("Loading: 0/100");
// Initial state
expect(progress.text).toEqual("Loading: 0/100");
// Update with just value
progress.update(50);
expect(progress.value).toEqual(50);
expect(progress.text).toEqual("Loading: 50/100");
// Update with just value
progress.update(50);
expect(progress.value).toEqual(50);
expect(progress.text).toEqual("Loading: 50/100");
// Update with value and props
progress.update(75, { status: "Downloading" });
expect(progress.value).toEqual(75);
expect(progress.text).toEqual("Downloading: 75/100");
// Update with value and props
progress.update(75, { status: "Downloading" });
expect(progress.value).toEqual(75);
expect(progress.text).toEqual("Downloading: 75/100");
expectCalls(progress, {
redraws: 2
});
expectCalls(progress, {
redraws: 2,
});
});
test("success and error methods", () => {
const progress = new Progress("Working");
const progress = new Progress("Working");
// Test success with default message
progress.success();
expectCalls(progress, {
successCalls: ["Working"]
});
// Test success with default message
progress.success();
expectCalls(progress, {
successCalls: ["Working"],
});
// Test success with custom message
const progress2 = new Progress("Working");
progress2.success("Completed");
expectCalls(progress2, {
successCalls: ["Completed"]
});
// Test success with custom message
const progress2 = new Progress("Working");
progress2.success("Completed");
expectCalls(progress2, {
successCalls: ["Completed"],
});
// Test error with default message
const progress3 = new Progress("Working");
progress3.error();
expectCalls(progress3, {
errorCalls: ["Working"]
});
// Test error with default message
const progress3 = new Progress("Working");
progress3.error();
expectCalls(progress3, {
errorCalls: ["Working"],
});
// Test error with custom message
const progress4 = new Progress("Working");
progress4.error("Failed");
expectCalls(progress4, {
errorCalls: ["Failed"]
});
// Test error with custom message
const progress4 = new Progress("Working");
progress4.error("Failed");
expectCalls(progress4, {
errorCalls: ["Failed"],
});
// Test error with Error object
const error = new Error("Something went wrong");
const progress5 = new Progress("Working");
progress5.error(error);
expectCalls(progress5, {
errorCalls: [error]
});
// Test error with Error object
const error = new Error("Something went wrong");
const progress5 = new Progress("Working");
progress5.error(error);
expectCalls(progress5, {
errorCalls: [error],
});
});
test("withProgress function", async () => {
const expectedResult = Symbol("result");
let bar!: Progress;
const expectedResult = Symbol("result");
let bar!: Progress;
try {
// We need to use try/finally to ensure we can access bar for validation
await withProgress("Processing", async (progress) => {
bar = progress;
expect(progress.text).toEqual("Processing");
try {
// We need to use try/finally to ensure we can access bar for validation
await withProgress("Processing", async (progress) => {
bar = progress;
expect(progress.text).toEqual("Processing");
// Update progress during operation
progress.value = 50;
// Update progress during operation
progress.value = 50;
return expectedResult;
});
} finally {
expectCalls(bar, {
successCalls: ["Completed"],
redraws: 1
});
}
return expectedResult;
});
} finally {
expectCalls(bar, {
successCalls: ["Completed"],
redraws: 1,
});
}
});
test("withProgress function with custom success text", async () => {
let bar!: Progress;
let bar!: Progress;
try {
await withProgress({
text: "Processing",
successText: "Task completed successfully"
}, async (progress) => {
bar = progress;
return "data";
});
} finally {
expectCalls(bar, {
successCalls: ["Task completed successfully"],
redraws: 0
});
}
try {
await withProgress({
text: "Processing",
successText: "Task completed successfully",
}, async (progress) => {
bar = progress;
return "data";
});
} finally {
expectCalls(bar, {
successCalls: ["Task completed successfully"],
redraws: 0,
});
}
});
test("withProgress function with success text function", async () => {
let bar!: Progress;
const data = { count: 42 };
let bar!: Progress;
const data = { count: 42 };
try {
await withProgress({
text: "Processing",
successText: (result: { count: number }) => `Processed ${result.count} items`
}, async (progress) => {
bar = progress;
return data;
});
} finally {
expectCalls(bar, {
successCalls: ["Processed 42 items"],
redraws: 0
});
}
try {
await withProgress({
text: "Processing",
successText: (result: { count: number }) =>
`Processed ${result.count} items`,
}, async (progress) => {
bar = progress;
return data;
});
} finally {
expectCalls(bar, {
successCalls: ["Processed 42 items"],
redraws: 0,
});
}
});
test("withProgress function with error", async () => {
const error = new Error("Process failed");
let bar!: Progress;
const error = new Error("Process failed");
let bar!: Progress;
try {
await withProgress({
text: "Processing",
failureText: "Task failed"
}, async (progress) => {
bar = progress;
throw error;
});
expect.unreachable();
} catch (e) {
expect(e).toEqual(error);
expectCalls(bar, {
errorCalls: ["Task failed"],
redraws: 0
});
}
try {
await withProgress({
text: "Processing",
failureText: "Task failed",
}, async (progress) => {
bar = progress;
throw error;
});
expect.unreachable();
} catch (e) {
expect(e).toEqual(error);
expectCalls(bar, {
errorCalls: ["Task failed"],
redraws: 0,
});
}
});
test("withProgress function with error text function", async () => {
const error = new Error("Process failed with code 500");
let bar!: Progress;
const error = new Error("Process failed with code 500");
let bar!: Progress;
try {
await withProgress({
text: "Processing",
failureText: (err) => `Error: ${err.message}`
}, async (progress) => {
bar = progress;
throw error;
});
expect.unreachable();
} catch (e) {
expect(e).toEqual(error);
expectCalls(bar, {
errorCalls: ["Error: Process failed with code 500"],
redraws: 0
});
}
});
try {
await withProgress({
text: "Processing",
failureText: (err) => `Error: ${err.message}`,
}, async (progress) => {
bar = progress;
throw error;
});
expect.unreachable();
} catch (e) {
expect(e).toEqual(error);
expectCalls(bar, {
errorCalls: ["Error: Process failed with code 500"],
redraws: 0,
});
}
});

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,288 +1,299 @@
import { beforeEach, expect, test } from "bun:test";
import { reset, expectCalls, getWidgets } from "./test/widget-mock.ts";
import { expectCalls, getWidgets, reset } from "./test/widget-mock.ts";
const { Spinner, defaultSpinnerOptions, withSpinner } = await import("@paperclover/console/Spinner");
type Spinner<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);
test("default options and rendering", () => {
const spinner = new Spinner("spin");
expect(spinner.props).toEqual({});
expect(spinner.fps).toEqual(defaultSpinnerOptions.fps);
expect(spinner.frames).toEqual(defaultSpinnerOptions.frames);
expect(defaultSpinnerOptions.frames).toHaveLength(10);
const spinner = new Spinner("spin");
expect(spinner.props).toEqual({});
expect(spinner.fps).toEqual(defaultSpinnerOptions.fps);
expect(spinner.frames).toEqual(defaultSpinnerOptions.frames);
expect(defaultSpinnerOptions.frames).toHaveLength(10);
let now = 0;
for (let i = 0; i < spinner.frames.length; i++) {
expect(spinner.format(now)).toEqual(`\x1b[94m${spinner.frames[i]}\x1b[39m spin`);
now += 1000 / spinner.fps;
}
let now = 0;
for (let i = 0; i < spinner.frames.length; i++) {
expect(spinner.format(now)).toEqual(
`\x1b[94m${spinner.frames[i]}\x1b[39m spin`,
);
now += 1000 / spinner.fps;
}
spinner.text = "something";
spinner.text = "something";
expectCalls(spinner, {
redraws: 1,
});
expectCalls(spinner, {
redraws: 1,
});
});
test("custom options apply correctly", () => {
const customFrames = ['A', 'B', 'C'];
const customFps = 24;
const customFrames = ["A", "B", "C"];
const customFps = 24;
const spinner = new Spinner({
text: "custom spinner",
frames: customFrames,
fps: customFps
});
const spinner = new Spinner({
text: "custom spinner",
frames: customFrames,
fps: customFps,
});
expect(spinner.frames).toEqual(customFrames);
expect(spinner.fps).toEqual(customFps);
expect(spinner.text).toEqual("custom spinner");
expect(spinner.frames).toEqual(customFrames);
expect(spinner.fps).toEqual(customFps);
expect(spinner.text).toEqual("custom spinner");
let now = 0;
for (let i = 0; i < spinner.frames.length; i++) {
expect(spinner.format(now)).toEqual(`\x1b[94m${spinner.frames[i]}\x1b[39m custom spinner`);
now += 1000 / spinner.fps;
}
let now = 0;
for (let i = 0; i < spinner.frames.length; i++) {
expect(spinner.format(now)).toEqual(
`\x1b[94m${spinner.frames[i]}\x1b[39m custom spinner`,
);
now += 1000 / spinner.fps;
}
});
test("custom color options", () => {
// Test with named color
const redSpinner = new Spinner({
text: "red spinner",
color: "red"
});
expect(redSpinner.format(0)).toEqual(`\x1b[31m${redSpinner.frames[0]}\x1b[39m red spinner`);
// Test with named color
const redSpinner = new Spinner({
text: "red spinner",
color: "red",
});
expect(redSpinner.format(0)).toEqual(
`\x1b[31m${redSpinner.frames[0]}\x1b[39m red spinner`,
);
// Test with hex color
const hexSpinner = new Spinner({
text: "hex spinner",
color: "#ff00ff"
});
expect(hexSpinner.format(0)).toContain(hexSpinner.frames[0]);
expect(hexSpinner.format(0)).toContain("hex spinner");
// Test with hex color
const hexSpinner = new Spinner({
text: "hex spinner",
color: "#ff00ff",
});
expect(hexSpinner.format(0)).toContain(hexSpinner.frames[0]);
expect(hexSpinner.format(0)).toContain("hex spinner");
// Test with rgb array
const rgbSpinner = new Spinner({
text: "rgb spinner",
color: [255, 0, 255]
});
expect(rgbSpinner.format(0)).toContain(rgbSpinner.frames[0]);
expect(rgbSpinner.format(0)).toContain("rgb spinner");
// Test with rgb array
const rgbSpinner = new Spinner({
text: "rgb spinner",
color: [255, 0, 255],
});
expect(rgbSpinner.format(0)).toContain(rgbSpinner.frames[0]);
expect(rgbSpinner.format(0)).toContain("rgb spinner");
// Test with color disabled
const noColorSpinner = new Spinner({
text: "no color",
color: false
});
expect(noColorSpinner.format(0)).toEqual(`${noColorSpinner.frames[0]} no color`);
// Test with color disabled
const noColorSpinner = new Spinner({
text: "no color",
color: false,
});
expect(noColorSpinner.format(0)).toEqual(
`${noColorSpinner.frames[0]} no color`,
);
});
test("invalid color throws error", () => {
expect(() => {
new Spinner({
text: "invalid color",
color: "invalidColor" as any
});
}).toThrow("Invalid color: invalidColor");
expect(() => {
new Spinner({
text: "invalid color",
color: "invalidColor" as any,
});
}).toThrow("Invalid color: invalidColor");
});
test("text getter and setter", () => {
const spinner = new Spinner("initial text");
expect(spinner.text).toEqual("initial text");
const spinner = new Spinner("initial text");
expect(spinner.text).toEqual("initial text");
spinner.text = "updated text";
expect(spinner.text).toEqual("updated text");
spinner.text = "updated text";
expect(spinner.text).toEqual("updated text");
expectCalls(spinner, {
redraws: 1
});
expectCalls(spinner, {
redraws: 1,
});
});
test("function for text field and props getter and setter", () => {
const textFn = (props: { count: number }) => `Items: ${props.count}`;
const spinner = new Spinner<{ count: number; newProp?: string }>({
text: textFn,
props: { count: 5 }
});
const textFn = (props: { count: number }) => `Items: ${props.count}`;
const spinner = new Spinner<{ count: number; newProp?: string }>({
text: textFn,
props: { count: 5 },
});
expect(spinner.text).toEqual("Items: 5");
expect(spinner.props).toEqual({ count: 5 });
expect(spinner.text).toEqual("Items: 5");
expect(spinner.props).toEqual({ count: 5 });
spinner.props = { count: 10 };
expect(spinner.text).toEqual("Items: 10");
expect(spinner.props).toEqual({ count: 10 });
spinner.props = { count: 10 };
expect(spinner.text).toEqual("Items: 10");
expect(spinner.props).toEqual({ count: 10 });
expectCalls(spinner, {
redraws: 1
});
expectCalls(spinner, {
redraws: 1,
});
// Adding new props should keep existing ones
spinner.props = { newProp: "value" };
expect(spinner.props).toEqual({ count: 10, newProp: "value" });
// Adding new props should keep existing ones
spinner.props = { newProp: "value" };
expect(spinner.props).toEqual({ count: 10, newProp: "value" });
expectCalls(spinner, {
redraws: 2
});
expectCalls(spinner, {
redraws: 2,
});
});
test("update method with string", () => {
const spinner = new Spinner("initial");
const spinner = new Spinner("initial");
spinner.update("updated via update");
expect(spinner.text).toEqual("updated via update");
spinner.update("updated via update");
expect(spinner.text).toEqual("updated via update");
expectCalls(spinner, {
redraws: 1
});
expectCalls(spinner, {
redraws: 1,
});
});
test("update method with props", () => {
const textFn = (props: { count: number, name?: string }) =>
`${props.name || 'Items'}: ${props.count}`;
const textFn = (props: { count: number; name?: string }) =>
`${props.name || "Items"}: ${props.count}`;
const spinner = new Spinner<{ count: number, name?: string }>({
text: textFn,
props: { count: 0 }
});
const spinner = new Spinner<{ count: number; name?: string }>({
text: textFn,
props: { count: 0 },
});
expect(spinner.text).toEqual("Items: 0");
expect(spinner.text).toEqual("Items: 0");
spinner.update({ count: 5 });
expect(spinner.text).toEqual("Items: 5");
spinner.update({ count: 5 });
expect(spinner.text).toEqual("Items: 5");
spinner.update({ name: "Products" });
expect(spinner.text).toEqual("Products: 5");
spinner.update({ name: "Products" });
expect(spinner.text).toEqual("Products: 5");
expectCalls(spinner, {
redraws: 2
});
expectCalls(spinner, {
redraws: 2,
});
});
test("success method", () => {
const spinner = new Spinner("working");
const spinner = new Spinner("working");
spinner.success();
expectCalls(spinner, {
successCalls: ["working"]
});
spinner.success();
expectCalls(spinner, {
successCalls: ["working"],
});
const spinner2 = new Spinner("working");
spinner2.success("completed");
expectCalls(spinner2, {
successCalls: ["completed"]
});
const spinner2 = new Spinner("working");
spinner2.success("completed");
expectCalls(spinner2, {
successCalls: ["completed"],
});
});
test("error method", () => {
const spinner = new Spinner("working");
const spinner = new Spinner("working");
spinner.error();
expectCalls(spinner, {
errorCalls: ["working"]
});
spinner.error();
expectCalls(spinner, {
errorCalls: ["working"],
});
const spinner2 = new Spinner("working");
spinner2.error("failed");
expectCalls(spinner2, {
errorCalls: ["failed"]
});
const spinner2 = new Spinner("working");
spinner2.error("failed");
expectCalls(spinner2, {
errorCalls: ["failed"],
});
const error = new Error("Something went wrong");
const spinner3 = new Spinner("working");
spinner3.error(error);
expectCalls(spinner3, {
errorCalls: [error]
});
const error = new Error("Something went wrong");
const spinner3 = new Spinner("working");
spinner3.error(error);
expectCalls(spinner3, {
errorCalls: [error],
});
});
test("withSpinner function", async () => {
const expectedResult = Symbol("unique");
let spin!: Spinner;
const expectedResult = Symbol("unique");
let spin!: Spinner;
const result = await withSpinner("Loading data", async (spinner) => {
spin = spinner;
expect(spinner.text).toEqual("Loading data");
return expectedResult;
});
const result = await withSpinner("Loading data", async (spinner) => {
spin = spinner;
expect(spinner.text).toEqual("Loading data");
return expectedResult;
});
expect(result).toEqual(expectedResult);
expectCalls(spin, {
successCalls: ["Completed"],
});
expect(result).toEqual(expectedResult);
expectCalls(spin, {
successCalls: ["Completed"],
});
});
test("withSpinner function with custom success text", async () => {
let spin!: Spinner;
const result = await withSpinner({
text: "Loading",
successText: "Data loaded successfully"
}, async (spinner) => {
spin = spinner;
return "data";
});
let spin!: Spinner;
const result = await withSpinner({
text: "Loading",
successText: "Data loaded successfully",
}, async (spinner) => {
spin = spinner;
return "data";
});
expect(result).toEqual("data");
expectCalls(spin, {
successCalls: ["Data loaded successfully"],
});
expect(result).toEqual("data");
expectCalls(spin, {
successCalls: ["Data loaded successfully"],
});
});
test("withSpinner function with success text function", async () => {
let spin!: Spinner;
const result = await withSpinner({
text: "Loading",
successText: (data: number[]) => `Loaded ${data.length} items`
}, async (spinner) => {
spin = spinner;
return [1, 2, 3];
});
let spin!: Spinner;
const result = await withSpinner({
text: "Loading",
successText: (data: number[]) => `Loaded ${data.length} items`,
}, async (spinner) => {
spin = spinner;
return [1, 2, 3];
});
expect(result).toEqual([1, 2, 3]);
expectCalls(spin, {
successCalls: ["Loaded 3 items"],
});
expect(result).toEqual([1, 2, 3]);
expectCalls(spin, {
successCalls: ["Loaded 3 items"],
});
});
test("withSpinner function with error", async () => {
const error = new Error("Failed to load data");
let spin!: Spinner;
const error = new Error("Failed to load data");
let spin!: Spinner;
try {
await withSpinner({
text: "Loading",
failureText: "Could not load data"
}, async (spinner) => {
spin = spinner;
throw error;
});
expect.unreachable();
} catch (e) {
expect(e).toEqual(error);
expectCalls(spin, {
errorCalls: ["Could not load data"]
});
}
try {
await withSpinner({
text: "Loading",
failureText: "Could not load data",
}, async (spinner) => {
spin = spinner;
throw error;
});
expect.unreachable();
} catch (e) {
expect(e).toEqual(error);
expectCalls(spin, {
errorCalls: ["Could not load data"],
});
}
});
test("withSpinner function with error text function", async () => {
const error = new Error("Network error");
let spin!: Spinner;
const error = new Error("Network error");
let spin!: Spinner;
try {
await withSpinner({
text: "Loading",
failureText: (err) => `Error: ${err.message}`
}, async (spinner) => {
spin = spinner;
throw error;
});
expect.unreachable();
} catch (e) {
expect(e).toEqual(error);
expectCalls(spin, {
errorCalls: ["Error: Network error"]
});
}
try {
await withSpinner({
text: "Loading",
failureText: (err) => `Error: ${err.message}`,
}, async (spinner) => {
spin = spinner;
throw error;
});
expect.unreachable();
} catch (e) {
expect(e).toEqual(error);
expectCalls(spin, {
errorCalls: ["Error: Network error"],
});
}
});

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'
? opts.text(spinner.props)
: opts.text
: 'Completed'
? typeof opts.text === "function"
? opts.text(spinner.props)
: opts.text
: "Completed",
);
}
return result;
} catch (error: any) {
spinner.error(
typeof opts.failureText === 'function' ? opts.failureText(error) : opts.failureText ?? error
typeof opts.failureText === "function"
? opts.failureText(error)
: (opts.failureText ?? error),
);
throw error;
}

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

@ -4,15 +4,17 @@ import chalk from "chalk";
beforeEach(reset);
test("built-in log levels", () => {
const console = require("@paperclover/console");
console.info("log");
expect(getBuffer()).toEqual(`${chalk.blueBright.bold("info")} log\n`);
reset();
console.warn("log");
expect(getBuffer()).toEqual(`${chalk.yellowBright.bold("warn")} ${chalk.yellowBright("log")}\n`);
reset();
console.error("log");
// Don't check the exact color formatting as it may vary
expect(getBuffer()).toContain("✖");
expect(getBuffer()).toContain("log");
const console = require("@paperclover/console");
console.info("log");
expect(getBuffer()).toEqual(`${chalk.blueBright.bold("info")} log\n`);
reset();
console.warn("log");
expect(getBuffer()).toEqual(
`${chalk.yellowBright.bold("warn")} ${chalk.yellowBright("log")}\n`,
);
reset();
console.error("log");
// Don't check the exact color formatting as it may vary
expect(getBuffer()).toContain("✖");
expect(getBuffer()).toContain("log");
});

View file

@ -1,71 +1,141 @@
export const isUnicodeSupported =
process.platform === 'win32'
? Boolean(process.env.CI) ||
Boolean(process.env.WT_SESSION) || // Windows Terminal
process.env.ConEmuTask === '{cmd::Cmder}' || // ConEmu and cmder
process.env.TERM_PROGRAM === 'vscode' ||
process.env.TERM === 'xterm-256color' ||
process.env.TERM === 'alacritty' ||
process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm'
: process.env.TERM !== 'linux';
export const isUnicodeSupported: boolean = process.platform === "win32"
? Boolean(process.env.CI) ||
Boolean(process.env.WT_SESSION) || // Windows Terminal
process.env.ConEmuTask === "{cmd::Cmder}" || // ConEmu and cmder
process.env.TERM_PROGRAM === "vscode" ||
process.env.TERM === "xterm-256color" ||
process.env.TERM === "alacritty" ||
process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm"
: process.env.TERM !== "linux";
export const errorSymbol = isUnicodeSupported ? '✖' : 'x';
export const successSymbol = isUnicodeSupported ? '✔' : '√';
export const infoSymbol = isUnicodeSupported ? '' : 'i';
export const warningSymbol = isUnicodeSupported ? '⚠' : '‼';
export const errorSymbol = isUnicodeSupported ? "✖" : "x";
export const successSymbol = isUnicodeSupported ? "✔" : "√";
export const infoSymbol = isUnicodeSupported ? "" : "i";
export const warningSymbol = isUnicodeSupported ? "⚠" : "‼";
let filters: string[] = [];
let filterGeneration = 0;
export function setLogFilter(...newFilters: Array<string | string[]>) {
filters = newFilters.flat().map(filter => filter.toLowerCase());
filterGeneration++;
filters = newFilters.flat().map((filter) => filter.toLowerCase());
filterGeneration++;
}
export function isLogVisible(id: string, defaultVisibility = true) {
for (const filter of filters) {
if (filter === '*') {
defaultVisibility = true;
} else if (filter === '-*') {
defaultVisibility = false;
} else if (filter === id || id.startsWith(filter + ':')) {
defaultVisibility = true;
} else if (filter === '-' + id || id.startsWith('-' + filter + ':')) {
defaultVisibility = false;
}
export function isLogVisible(id: string, defaultVisibility = true): boolean {
for (const filter of filters) {
if (filter === "*") {
defaultVisibility = true;
} else if (filter === "-*") {
defaultVisibility = false;
} else if (filter === id || id.startsWith(filter + ":")) {
defaultVisibility = true;
} else if (filter === "-" + id || id.startsWith("-" + filter + ":")) {
defaultVisibility = false;
}
return defaultVisibility;
}
return defaultVisibility;
}
if (process.env.DEBUG !== undefined) {
setLogFilter(
String(process.env.DEBUG)
.split(',')
.map(x => x.trim())
.map(x => (['1', 'true', 'all'].includes(x.toLowerCase()) ? '*' : x))
);
setLogFilter(
String(process.env.DEBUG)
.split(",")
.map((x) => x.trim())
.map((x) => (["1", "true", "all"].includes(x.toLowerCase()) ? "*" : x)),
);
}
/** Taken from https://github.com/debug-js/debug/blob/d1616622e4d404863c5a98443f755b4006e971dc/src/node.js#L35. */
const debugColors = [
20, 21, 26, 27, 32, 33, 38, 39, 40, 41, 42, 43, 44, 45, 56, 57, 62, 63, 68, 69, 74, 75, 76, 77,
78, 79, 80, 81, 92, 93, 98, 99, 112, 113, 128, 129, 134, 135, 148, 149, 160, 161, 162, 163, 164,
165, 166, 167, 168, 169, 170, 171, 172, 173, 178, 179, 184, 185, 196, 197, 198, 199, 200, 201,
202, 203, 204, 205, 206, 207, 208, 209, 214, 215, 220, 221,
20,
21,
26,
27,
32,
33,
38,
39,
40,
41,
42,
43,
44,
45,
56,
57,
62,
63,
68,
69,
74,
75,
76,
77,
78,
79,
80,
81,
92,
93,
98,
99,
112,
113,
128,
129,
134,
135,
148,
149,
160,
161,
162,
163,
164,
165,
166,
167,
168,
169,
170,
171,
172,
173,
178,
179,
184,
185,
196,
197,
198,
199,
200,
201,
202,
203,
204,
205,
206,
207,
208,
209,
214,
215,
220,
221,
];
/** Converts non string objects into a string the way Node.js' console.log does it. */
function stringify(...data: any[]) {
return data
.map(obj => {
if (typeof obj === 'string') {
return obj;
} else if (obj instanceof Error) {
return formatErrorObj(obj);
}
return inspect(obj, false, 4, true);
})
.join(' ');
return data
.map((obj) => {
if (typeof obj === "string") {
return obj;
} else if (obj instanceof Error) {
return formatErrorObj(obj);
}
return inspect(obj, false, 4, true);
})
.join(" ");
}
/**
@ -74,56 +144,55 @@ function stringify(...data: any[]) {
* Taken from https://github.com/debug-js/debug/blob/master/src/common.js.
*/
function selectColor(namespace: string) {
let hash = 0;
let hash = 0;
for (let i = 0; i < namespace.length; i++) {
hash = (hash << 5) - hash + namespace.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
for (let i = 0; i < namespace.length; i++) {
hash = (hash << 5) - hash + namespace.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
return debugColors[Math.abs(hash) % debugColors.length]!;
return debugColors[Math.abs(hash) % debugColors.length]!;
}
const formatImplementation = {
's': (data: StringLike) => String(data),
'd': (data: number) => String(data),
'i': (data: number) => String(Math.floor(data)),
'f': (data: number) => String(data),
'x': (data: number) => data.toString(16),
'X': (data: number) => data.toString(16).toUpperCase(),
'o': (data: any) => JSON.stringify(data),
'O': (data: any) => JSON.stringify(data, null, 2),
'c': () => '',
'j': (data: any) => JSON.stringify(data),
s: (data: StringLike) => String(data),
d: (data: number) => String(data),
i: (data: number) => String(Math.floor(data)),
f: (data: number) => String(data),
x: (data: number) => data.toString(16),
X: (data: number) => data.toString(16).toUpperCase(),
o: (data: any) => JSON.stringify(data),
O: (data: any) => JSON.stringify(data, null, 2),
c: () => "",
j: (data: any) => JSON.stringify(data),
};
function format(fmt: any, ...args: any[]) {
if (typeof fmt === 'string') {
let index = 0;
const result = fmt.replace(/%[%sdifoxXcj]/g, match => {
if (match === '%%') {
return '%';
}
const arg = args[index++];
return (formatImplementation as any)[match[1]!](arg);
});
if (typeof fmt === "string") {
let index = 0;
const result = fmt.replace(/%[%sdifoxXcj]/g, (match) => {
if (match === "%%") {
return "%";
}
const arg = args[index++];
return (formatImplementation as any)[match[1]!](arg);
});
if (index === 0 && args.length > 0) {
return result + ' ' + stringify(...args);
}
return result;
if (index === 0 && args.length > 0) {
return result + " " + stringify(...args);
}
return stringify(fmt, ...args);
return result;
}
return stringify(fmt, ...args);
}
const LogFunction = {
__proto__: Function.prototype,
[Symbol.for('nodejs.util.inspect.custom')](depth: number, options: any) {
return options.stylize(`[LogFunction: ${(this as any).name}]`, 'special');
},
/** @internal */
const LogFunction: {} = {
__proto__: Function.prototype,
[Symbol.for("nodejs.util.inspect.custom")](depth: number, options: any) {
return options.stylize(`[LogFunction: ${(this as any).name}]`, "special");
},
};
/**
@ -138,108 +207,124 @@ const LogFunction = {
* - Using chalk or another formatter on the namespace name.
*/
export function scoped(
name: string,
opts: CustomLoggerOptions | CustomLoggerColor = {}
name: string,
opts: CustomLoggerOptions | CustomLoggerColor = {},
): LogFunction {
if (typeof opts === 'string' || Array.isArray(opts) || typeof opts === 'number') {
opts = { color: opts };
if (
typeof opts === "string" ||
Array.isArray(opts) ||
typeof opts === "number"
) {
opts = { color: opts };
}
const {
id = name,
color = undefined,
coloredText = false,
boldText = false,
debug = false,
} = opts;
const strippedName = stripAnsi(name);
const colorFn = name.includes("\x1b")
? chalk
: color
? getColor(color)
: chalk.ansi256(selectColor(name));
const coloredName = colorFn.bold(name);
const fn = ((fmt: unknown, ...args: any[]) => {
if (!fn.visible) {
return;
}
const {
id = name,
color = undefined,
coloredText = false,
boldText = false,
debug = false,
} = opts;
const strippedName = stripAnsi(name);
const colorFn = name.includes('\x1b')
? chalk
: color
? getColor(color)
: chalk.ansi256(selectColor(name));
const coloredName = colorFn.bold(name);
const fn = ((fmt: unknown, ...args: any[]) => {
if (!fn.visible) {
return;
}
const data = format(fmt, ...args).replace(/\n/g, '\n ' + ' '.repeat(strippedName.length));
const data = format(fmt, ...args).replace(
/\n/g,
"\n " + " ".repeat(strippedName.length),
);
if (fmt === undefined && args.length === 0) {
writeLine();
} else {
writeLine(
coloredName + ' ' + (coloredText ? (boldText ? colorFn.bold(data) : colorFn(data)) : data)
);
}
}) as LogFunction;
Object.setPrototypeOf(fn, LogFunction);
Object.defineProperty(fn, 'name', { value: id });
let gen = filterGeneration;
let visible = isLogVisible(id, !debug);
Object.defineProperty(fn, 'visible', {
get: () => {
if (gen !== filterGeneration) {
gen = filterGeneration;
visible = isLogVisible(id, !debug);
}
return visible;
}
});
return fn;
if (fmt === undefined && args.length === 0) {
writeLine();
} else {
writeLine(
coloredName +
" " +
(coloredText ? boldText ? colorFn.bold(data) : colorFn(data) : data),
);
}
}) as LogFunction;
Object.setPrototypeOf(fn, LogFunction);
Object.defineProperty(fn, "name", { value: id });
let gen = filterGeneration;
let visible = isLogVisible(id, !debug);
Object.defineProperty(fn, "visible", {
get: () => {
if (gen !== filterGeneration) {
gen = filterGeneration;
visible = isLogVisible(id, !debug);
}
return visible;
},
});
return fn;
}
/** Built in blue "info" logger. */
export const info = /* @__PURE__ */ scoped('info', {
color: 'blueBright',
export const info: LogFunction = /* @__PURE__ */ scoped("info", {
color: "blueBright",
});
/** Built in yellow "warn" logger. */
export const warn = /* @__PURE__ */ scoped('warn', {
color: 'yellowBright',
coloredText: true,
export const warn: LogFunction = /* @__PURE__ */ scoped("warn", {
color: "yellowBright",
coloredText: true,
});
const _trace = /* @__PURE__ */ scoped('trace', {
color: 208,
const _trace: LogFunction = /* @__PURE__ */ scoped("trace", {
color: 208,
});
/** Built in orange "trace" logger. Prints a stack trace after the message. */
export const trace = /* @__PURE__ */ ((trace: any) => (Object.defineProperty(trace, 'visible', { get: () => _trace.visible }), trace))(function trace(...data: any[]) {
if (_trace.visible) {
_trace(...(data.length === 0 ? [' '] : data));
writeLine(formatStackTrace(new Error()).split('\n').slice(1).join('\n'));
}
export const trace: LogFunction = /* @__PURE__ */ ((trace: any) => (
Object.defineProperty(trace, "visible", { get: () => _trace.visible }), trace
))(function trace(...data: any[]) {
if (_trace.visible) {
_trace(...(data.length === 0 ? [" "] : data));
writeLine(formatStackTrace(new Error()).split("\n").slice(1).join("\n"));
}
}) as typeof _trace;
/** Built in red "error" logger, uses a unicode X instead of the word Error. */
export const error = /* @__PURE__ */ scoped(errorSymbol, {
id: 'error',
color: 'redBright',
coloredText: true,
boldText: true,
export const error: LogFunction = /* @__PURE__ */ scoped(errorSymbol, {
id: "error",
color: "redBright",
coloredText: true,
boldText: true,
});
/** Built in cyan "debug" logger. */
export const debug = scoped('debug', {
color: 'cyanBright',
debug: true,
export const debug: LogFunction = scoped("debug", {
color: "cyanBright",
debug: true,
});
/** Built in green "success" logger, uses a unicode Check instead of the word Success. */
export const success = /* @__PURE__ */ scoped(successSymbol, {
id: 'success',
color: 'greenBright',
coloredText: true,
boldText: true,
export const success: LogFunction = /* @__PURE__ */ scoped(successSymbol, {
id: "success",
color: "greenBright",
coloredText: true,
boldText: true,
});
import chalk, { type ChalkInstance } from 'chalk';
import { inspect } from 'node:util';
import { type LogFunction, type CustomLoggerColor, type CustomLoggerOptions, type StringLike, getColor } from './internal.ts';
import { formatErrorObj, formatStackTrace } from './error.ts';
import stripAnsi from 'strip-ansi';
import { writeLine } from './Widget.ts';
import chalk, { type ChalkInstance } from "chalk";
import { inspect } from "node:util";
import {
type CustomLoggerColor,
type CustomLoggerOptions,
getColor,
type LogFunction,
type StringLike,
} from "./internal.ts";
import { formatErrorObj, formatStackTrace } from "./error.ts";
import stripAnsi from "strip-ansi";
import { writeLine } from "./Widget.ts";
export { writeLine } from './Widget.ts';
export { writeLine } from "./Widget.ts";

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", () => {
@ -16,9 +21,9 @@ test("formatErrorObj with PrintableError interface", () => {
const printableError = new Error("Custom message") as any;
printableError.name = "CustomError";
printableError.description = "This is a detailed description of the error";
const formatted = formatErrorObj(printableError);
// Should contain name, message and description
expect(formatted).toContain("CustomError: Custom message");
expect(formatted).toContain("This is a detailed description of the error");
@ -28,9 +33,9 @@ test("formatErrorObj with PrintableError interface", () => {
test("formatErrorObj with hideName option", () => {
const printableError = new Error("Just the message") as any;
printableError.hideName = true;
const formatted = formatErrorObj(printableError);
// Should not contain "Error:"
expect(formatted).not.toContain("Error:");
// Don't check the exact output since the stack trace might vary
@ -39,9 +44,9 @@ test("formatErrorObj with hideName option", () => {
test("formatErrorObj with hideStack option", () => {
const printableError = new Error("No stack trace") as any;
printableError.hideStack = true;
const formatted = formatErrorObj(printableError);
// Should contain message but not stack trace
expect(formatted).toContain("Error: No stack trace");
expect(formatted).not.toContain("at <anonymous>");
@ -50,7 +55,7 @@ test("formatErrorObj with hideStack option", () => {
test("formatStackTrace with V8 style stack", () => {
const error = new Error("Stack trace test");
const formatted = formatStackTrace(error);
// Should format the stack trace
expect(formatted).toContain("at ");
// May not have color codes in test environment
@ -59,9 +64,9 @@ test("formatStackTrace with V8 style stack", () => {
test("formatStackTrace with empty stack", () => {
const error = new Error("No stack");
error.stack = undefined;
const formatted = formatStackTrace(error);
// Should handle undefined stack
expect(formatted).toEqual("");
});
@ -70,7 +75,7 @@ test("platformSimplifyErrorPath with cwd", () => {
const cwd = process.cwd();
const fullPath = `${cwd}/src/error.ts`;
const simplified = platformSimplifyErrorPath(fullPath);
// Should convert to a relative path
expect(simplified).toEqual("./src/error.ts");
});
@ -78,7 +83,7 @@ test("platformSimplifyErrorPath with cwd", () => {
test("platformSimplifyErrorPath with external path", () => {
const externalPath = "/usr/local/lib/node_modules/module.js";
const simplified = platformSimplifyErrorPath(externalPath);
// Should keep external paths unchanged
expect(simplified).toEqual(externalPath);
});
@ -86,18 +91,22 @@ test("platformSimplifyErrorPath with external path", () => {
test("CLIError class", () => {
const cliError = new CLIError(
"Command failed",
"Try running with --help for available options"
"Try running with --help for available options",
);
expect(cliError.message).toEqual("Command failed");
expect(cliError.description).toEqual("Try running with --help for available options");
expect(cliError.description).toEqual(
"Try running with --help for available options",
);
expect(cliError.hideStack).toEqual(true);
expect(cliError.hideName).toEqual(true);
const formatted = formatErrorObj(cliError);
// Should format according to the PrintableError interface
expect(formatted).toEqual("Command failed\nTry running with --help for available options\n");
expect(formatted).toEqual(
"Command failed\nTry running with --help for available options\n",
);
expect(formatted).not.toContain("CLIError");
expect(formatted).not.toContain("at ");
});
@ -109,9 +118,9 @@ test("pretty printing of objects in stack traces", () => {
JSON.parse("{invalid}");
} catch (error) {
const formatted = formatErrorObj(error as Error);
// Should format the error message
expect(formatted).toContain("SyntaxError");
expect(formatted).toContain("at ");
}
});
});

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)})`
: [
'(',
getColoredDirname(file),
// Leave the first slash on linux.
chalk.green(path.basename(file)),
':',
line + ':' + column,
')',
].join('')
: '<unknown>';
? isBuiltin(file) ? `(${chalk.magenta(file)})` : [
"(",
getColoredDirname(file),
// Leave the first slash on linux.
chalk.green(path.basename(file)),
":",
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,33 +1,36 @@
import { beforeEach, expect, test, mock } from "bun:test";
import { beforeEach, expect, mock, test } from "bun:test";
import { spawn } from "child_process";
// We need to use a subprocess to test injection since it happens immediately on import
// and would affect our test environment
function runSubprocess(testType: string, env: Record<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 = "";
let stderr = "";
subprocess.stdout.on("data", (data) => {
stdout += data.toString();
});
subprocess.stderr.on("data", (data) => {
stderr += data.toString();
});
subprocess.on("error", (error) => {
reject(error);
});
subprocess.on("close", (code) => {
if (code !== 0) {
reject(new Error(`Subprocess exited with code ${code}: ${stderr}`));
@ -40,18 +43,18 @@ function runSubprocess(testType: string, env: Record<string, string> = {}): Prom
test("basic injection is applied correctly", async () => {
// Enable debug logs with DEBUG=1 and force color with FORCE_COLOR
const output = await runSubprocess("basic-injection", {
const output = await runSubprocess("basic-injection", {
DEBUG: "1",
FORCE_COLOR: "1"
FORCE_COLOR: "1",
});
// Check that console methods are correctly injected
expect(output).toContain("INJECTED_LOG");
expect(output).toContain("INJECTED_INFO");
expect(output).toContain("INJECTED_WARN");
expect(output).toContain("INJECTED_ERROR");
expect(output).toContain("INJECTED_DEBUG");
// Assert should only output when condition is false
expect(output).toContain("INJECTED_ASSERT");
expect(output).not.toContain("This should not appear");
@ -59,18 +62,18 @@ test("basic injection is applied correctly", async () => {
test("timer functions work correctly", async () => {
const output = await runSubprocess("time-functions");
// Check timer functions
expect(output).toContain("TIMER_LABEL");
expect(output).toContain("ms]"); // Time output should include milliseconds
// Warning for non-existent timer
expect(output).toContain("does not exist");
});
test("count functions work correctly", async () => {
const output = await runSubprocess("count-functions");
// Check counter functions
expect(output).toContain("COUNTER_LABEL: 1");
expect(output).toContain("COUNTER_LABEL: 2");
@ -79,9 +82,9 @@ test("count functions work correctly", async () => {
test("environment variables affect injection", async () => {
const output = await runSubprocess("disabled-injection", { NO_COLOR: "1" });
// When NO_COLOR is set, output should still have the message
expect(output).toContain("DISABLED_COLOR_LOG");
// Note: Chalk may still output color codes even with NO_COLOR=1 in some environments
// So we're not checking for absence of color codes here
});
});

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,164 +1,170 @@
import chalk, { type ChalkInstance } from 'chalk';
import { inspect } from 'node:util';
import chalk, { type ChalkInstance } from "chalk";
import { inspect } from "node:util";
export function convertHSVtoRGB(h: number, s: number, v: number): [number, number, number] {
let r, g, b;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0:
(r = v), (g = t), (b = p);
break;
case 1:
(r = q), (g = v), (b = p);
break;
case 2:
(r = p), (g = v), (b = t);
break;
case 3:
(r = p), (g = q), (b = v);
break;
case 4:
(r = t), (g = p), (b = v);
break;
case 5:
(r = v), (g = p), (b = q);
break;
default:
return [0, 0, 0];
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
export function convertHSVtoRGB(
h: number,
s: number,
v: number,
): [number, number, number] {
let r, g, b;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0:
(r = v), (g = t), (b = p);
break;
case 1:
(r = q), (g = v), (b = p);
break;
case 2:
(r = p), (g = v), (b = t);
break;
case 3:
(r = p), (g = q), (b = v);
break;
case 4:
(r = t), (g = p), (b = v);
break;
case 5:
(r = v), (g = p), (b = q);
break;
default:
return [0, 0, 0];
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
/** Converts non string objects into a string the way Node.js' console.log does it. */
export function stringify(...data: any[]) {
return data.map(obj => (typeof obj === 'string' ? obj : inspect(obj, false, 4, true))).join(' ');
return data.map(
(obj) => (typeof obj === "string" ? obj : inspect(obj, false, 4, true)),
).join(" ");
}
let buffer = '';
let buffer = "";
let bufferNeedsUnfreeze = false;
let exiting = false;
export function writeToStderr(data: string, needsUnfreeze = false) {
buffer += data;
if (exiting) flushStderr();
if (needsUnfreeze) bufferNeedsUnfreeze = true;
buffer += data;
if (exiting) flushStderr();
if (needsUnfreeze) bufferNeedsUnfreeze = true;
}
const stderr = process.stderr;
export function flushStderr() {
if (buffer) {
if (bufferNeedsUnfreeze) {
buffer += '\u001B[?2026l';
bufferNeedsUnfreeze = false;
}
stderr.write(buffer);
buffer = '';
if (buffer) {
if (bufferNeedsUnfreeze) {
buffer += "\u001B[?2026l";
bufferNeedsUnfreeze = false;
}
stderr.write(buffer);
buffer = "";
}
}
process.on('exit', () => {
exiting = true;
buffer += '\x1b[0;39;49m';
flushStderr();
process.on("exit", () => {
exiting = true;
buffer += "\x1b[0;39;49m";
flushStderr();
});
export type CustomLoggerColor =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white'
| 'gray'
| 'grey'
| 'blackBright'
| 'redBright'
| 'greenBright'
| 'yellowBright'
| 'blueBright'
| 'magentaBright'
| 'cyanBright'
| 'whiteBright'
| `#${string}`
| number
| [number, number, number];
| "black"
| "red"
| "green"
| "yellow"
| "blue"
| "magenta"
| "cyan"
| "white"
| "gray"
| "grey"
| "blackBright"
| "redBright"
| "greenBright"
| "yellowBright"
| "blueBright"
| "magentaBright"
| "cyanBright"
| "whiteBright"
| `#${string}`
| number
| [number, number, number];
export interface CustomLoggerOptions {
id?: string;
color?: CustomLoggerColor;
coloredText?: boolean;
boldText?: boolean;
level?: number;
debug?: boolean;
id?: string;
color?: CustomLoggerColor;
coloredText?: boolean;
boldText?: boolean;
level?: number;
debug?: boolean;
}
/** Matches `string`, `number`, and other objects with a `.toString()` method. */
export interface StringLike {
toString(): string;
toString(): string;
}
export interface FormatStringArgs {
'%s': StringLike | null | undefined;
'%d': number | null | undefined;
'%i': number | null | undefined;
'%f': number | null | undefined;
'%x': number | null | undefined;
'%X': number | null | undefined;
'%o': any;
'%O': any;
'%c': string | null | undefined;
'%j': any;
"%s": StringLike | null | undefined;
"%d": number | null | undefined;
"%i": number | null | undefined;
"%f": number | null | undefined;
"%x": number | null | undefined;
"%X": number | null | undefined;
"%o": any;
"%O": any;
"%c": string | null | undefined;
"%j": any;
}
export type ProcessFormatString<S> = S extends `${string}%${infer K}${infer B}`
? `%${K}` extends keyof FormatStringArgs
? `%${K}` extends keyof FormatStringArgs
? [FormatStringArgs[`%${K}`], ...ProcessFormatString<B>]
: ProcessFormatString<B>
: [];
: ProcessFormatString<B>
: [];
export type LogData = string | number | boolean | object | null | undefined;
export interface LogFunction {
/**
* Writes data to the log. The first argument can be a printf-style format string, or usage
* similar to `console.log`. Handles formatting objects including `Error` objects with pretty
* colorized stack traces.
*
* List of formatters:
*
* - %s - String.
* - %d, %f - Number.
* - %i - Integer.
* - %x - Hex.
* - %X - Hex (uppercase)
* - %o - Object.
* - %O - Object (pretty printed).
* - %j - JSON.
*/
<S extends LogData>(data?: S, ...a: ProcessFormatString<S>): void;
/** Calling a logger function with no arguments prints a blank line. */
(): void;
/**
* Writes data to the log. The first argument can be a printf-style format string, or usage
* similar to `console.log`. Handles formatting objects including `Error` objects with pretty
* colorized stack traces.
*
* List of formatters:
*
* - %s - String.
* - %d, %f - Number.
* - %i - Integer.
* - %x - Hex.
* - %X - Hex (uppercase)
* - %o - Object.
* - %O - Object (pretty printed).
* - %j - JSON.
*/
<S extends LogData>(data?: S, ...a: ProcessFormatString<S>): void;
/** Calling a logger function with no arguments prints a blank line. */
(): void;
visible: boolean;
name: string;
visible: boolean;
name: string;
}
export function getColor(color: CustomLoggerColor): ChalkInstance {
if (typeof color === 'string') {
if (color in chalk) {
return (chalk as any)[color];
} else if (color.startsWith('#') || color.match(/^#[0-9a-fA-F]{6}$/)) {
return chalk.hex(color);
}
throw new Error(`Invalid color: ${color}`);
} else if (Array.isArray(color)) {
return chalk.rgb(color[0], color[1], color[2]);
if (typeof color === "string") {
if (color in chalk) {
return (chalk as any)[color];
} else if (color.startsWith("#") || color.match(/^#[0-9a-fA-F]{6}$/)) {
return chalk.hex(color);
}
return chalk.ansi256(color);
throw new Error(`Invalid color: ${color}`);
} else if (Array.isArray(color)) {
return chalk.rgb(color[0], color[1], color[2]);
}
return chalk.ansi256(color);
}

View file

@ -4,53 +4,53 @@
// First argument tells what to test
const testType = process.argv[2];
if (testType === 'basic-injection') {
if (testType === "basic-injection") {
// Import and test basic injection
import('@paperclover/console/inject').then(() => {
import("@paperclover/console/inject").then(() => {
// Check that console methods are replaced
console.log('INJECTED_LOG');
console.info('INJECTED_INFO');
console.warn('INJECTED_WARN');
console.error('INJECTED_ERROR');
console.debug('INJECTED_DEBUG');
console.log("INJECTED_LOG");
console.info("INJECTED_INFO");
console.warn("INJECTED_WARN");
console.error("INJECTED_ERROR");
console.debug("INJECTED_DEBUG");
// Assert
console.assert(true, 'This should not appear');
console.assert(false, 'INJECTED_ASSERT');
console.assert(true, "This should not appear");
console.assert(false, "INJECTED_ASSERT");
// Exit when done
setTimeout(() => process.exit(0), 100);
});
} else if (testType === 'time-functions') {
} else if (testType === "time-functions") {
// Import and test timing functions
import('@paperclover/console/inject').then(() => {
console.time('TIMER_LABEL');
console.timeLog('TIMER_LABEL');
console.timeEnd('TIMER_LABEL');
import("@paperclover/console/inject").then(() => {
console.time("TIMER_LABEL");
console.timeLog("TIMER_LABEL");
console.timeEnd("TIMER_LABEL");
// Test invalid timer
console.timeEnd('NONEXISTENT_TIMER');
console.timeEnd("NONEXISTENT_TIMER");
// Exit when done
setTimeout(() => process.exit(0), 100);
});
} else if (testType === 'count-functions') {
} else if (testType === "count-functions") {
// Import and test count functions
import('@paperclover/console/inject').then(() => {
console.count('COUNTER_LABEL');
console.count('COUNTER_LABEL');
console.countReset('COUNTER_LABEL');
console.count('COUNTER_LABEL');
import("@paperclover/console/inject").then(() => {
console.count("COUNTER_LABEL");
console.count("COUNTER_LABEL");
console.countReset("COUNTER_LABEL");
console.count("COUNTER_LABEL");
// Exit when done
setTimeout(() => process.exit(0), 100);
});
} else if (testType === 'disabled-injection') {
} else if (testType === "disabled-injection") {
// NO_COLOR should be passed via spawn options
import('@paperclover/console/inject').then(() => {
console.log('DISABLED_COLOR_LOG');
import("@paperclover/console/inject").then(() => {
console.log("DISABLED_COLOR_LOG");
// Exit when done
setTimeout(() => process.exit(0), 100);
});
}
}

View file

@ -1,103 +1,103 @@
//! Mock `@paperclover/console/Widget` to make testing easier.
import { mock, expect } from "bun:test";
import { expect, mock } from "bun:test";
import assert from "node:assert";
import type { Widget } from "../Widget.ts";
process.env.CI = 'true';
process.env.FORCE_COLOR = '10';
process.env.CI = "true";
process.env.FORCE_COLOR = "10";
let buffer = '';
let buffer = "";
let widgets: MockWidget[] = [];
globalThis.setTimeout = (() => {
throw new Error('Do not call setTimeout in tests');
throw new Error("Do not call setTimeout in tests");
}) as any;
globalThis.clearTimeout = (() => {
throw new Error('Do not call clearTimeout in tests');
throw new Error("Do not call clearTimeout in tests");
}) as any;
abstract class MockWidget {
abstract format(now: number): string;
abstract fps: number;
abstract format(now: number): string;
abstract fps: number;
isActive = true;
redrawCount = 0;
successCalls: string[] = [];
errorCalls: (string | Error)[] = [];
stopCalls: (string | undefined)[] = [];
isActive = true;
redrawCount = 0;
successCalls: string[] = [];
errorCalls: (string | Error)[] = [];
stopCalls: (string | undefined)[] = [];
constructor() {
}
constructor() {
}
stop(finalMessage?: string): void {
this.stopCalls.push(finalMessage);
this.isActive = false;
}
stop(finalMessage?: string): void {
this.stopCalls.push(finalMessage);
this.isActive = false;
}
protected redraw(): void {
this.redrawCount++;
}
protected redraw(): void {
this.redrawCount++;
}
success(message: string): void {
this.successCalls.push(message);
this.isActive = false;
}
success(message: string): void {
this.successCalls.push(message);
this.isActive = false;
}
error(message: string | Error): void {
this.errorCalls.push(message);
this.isActive = false;
}
error(message: string | Error): void {
this.errorCalls.push(message);
this.isActive = false;
}
get active(): boolean {
return this.isActive;
}
get active(): boolean {
return this.isActive;
}
}
const warn = console.warn;
mock.module(require.resolve('../Widget.ts'), () => ({
writeLine: (line: string) => buffer += line + '\n',
errorAllWidgets: () => {
warn("errorAllWidgets is not implemented");
},
Widget: MockWidget,
mock.module(require.resolve("../Widget.ts"), () => ({
writeLine: (line: string) => buffer += line + "\n",
errorAllWidgets: () => {
warn("errorAllWidgets is not implemented");
},
Widget: MockWidget,
}));
mock.module('node:fs', () => ({
writeSync: (fd: number, data: string) => {
assert(fd === 2, 'writeSync must be called with stderr');
buffer += data;
},
mock.module("node:fs", () => ({
writeSync: (fd: number, data: string) => {
assert(fd === 2, "writeSync must be called with stderr");
buffer += data;
},
}));
export function reset() {
buffer = '';
widgets = [];
buffer = "";
widgets = [];
}
export function getBuffer() {
return buffer;
return buffer;
}
export function getWidgets() {
return widgets as any;
return widgets as any;
}
export function expectCalls(widget: Widget, checks: {
successCalls?: string[];
errorCalls?: (string | Error)[];
redraws?: number;
stopCalls?: (string | undefined)[];
successCalls?: string[];
errorCalls?: (string | Error)[];
redraws?: number;
stopCalls?: (string | undefined)[];
}) {
const mockWidget = widget as unknown as MockWidget;
expect({
successCalls: mockWidget.successCalls,
errorCalls: mockWidget.errorCalls,
redraws: mockWidget.redrawCount,
stopCalls: mockWidget.stopCalls,
}).toEqual({
successCalls: checks.successCalls ?? [],
errorCalls: checks.errorCalls ?? [],
redraws: checks.redraws ?? 0,
stopCalls: checks.stopCalls ?? [],
});
}
const mockWidget = widget as unknown as MockWidget;
expect({
successCalls: mockWidget.successCalls,
errorCalls: mockWidget.errorCalls,
redraws: mockWidget.redrawCount,
stopCalls: mockWidget.stopCalls,
}).toEqual({
successCalls: checks.successCalls ?? [],
errorCalls: checks.errorCalls ?? [],
redraws: checks.redraws ?? 0,
stopCalls: checks.stopCalls ?? [],
});
}