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.info("Hello, world!");
console.warn('This is a warning'); console.warn("This is a warning");
console.error('This is an error'); console.error("This is an error");
console.debug('This is a debug message'); console.debug("This is a debug message");
console.success('This is a success message'); console.success("This is a success message");
const custom = console.scoped('my_own'); const custom = console.scoped("my_own");
custom('Hello, world!'); custom("Hello, world!");

View file

@ -1,6 +1,6 @@
import '@paperclover/console/inject'; import "@paperclover/console/inject";
console.info('Hello, world!'); console.info("Hello, world!");
console.warn('This is a warning'); console.warn("This is a warning");
console.error('This is an error'); console.error("This is an error");
console.debug('This is a debug message'); console.debug("This is a debug message");

View file

@ -1,11 +1,11 @@
import { withProgress } from "@paperclover/console/Progress"; import { withProgress } from "@paperclover/console/Progress";
await withProgress('do a task', async (progress) => { await withProgress("do a task", async (progress) => {
// mutate the progress object // mutate the progress object
progress.total = 100; progress.total = 100;
for (let i = 0; i < progress.total; i++) { 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; progress.value += 1;
} }
}); });

View file

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

View file

@ -1,16 +1,20 @@
import chalk from 'chalk'; import chalk from "chalk";
import { convertHSVtoRGB, getColor, type CustomLoggerColor } from './internal.ts'; import {
import { defaultSpinnerOptions } from './Spinner.ts'; convertHSVtoRGB,
import { isUnicodeSupported } from './console.ts'; type CustomLoggerColor,
import { Widget } from './Widget.ts'; getColor,
} from "./internal.ts";
import { defaultSpinnerOptions } from "./Spinner.ts";
import { isUnicodeSupported } from "./console.ts";
import { Widget } from "./Widget.ts";
const boxChars = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉']; const boxChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
const fullBox = '█'; const fullBox = "█";
const asciiChars = { const asciiChars = {
start: '[', start: "[",
end: ']', end: "]",
empty: ' ', empty: " ",
fill: '=', fill: "=",
}; };
/** /**
@ -26,7 +30,7 @@ function getUnicodeBar(progress: number, width: number) {
return fullBox.repeat(width); return fullBox.repeat(width);
} }
if (progress <= 0 || isNaN(progress)) { if (progress <= 0 || isNaN(progress)) {
return ' '.repeat(width); return " ".repeat(width);
} }
const wholeWidth = Math.floor(progress * width); const wholeWidth = Math.floor(progress * width);
@ -34,11 +38,11 @@ function getUnicodeBar(progress: number, width: number) {
const partWidth = Math.floor(remainderWidth * 8); const partWidth = Math.floor(remainderWidth * 8);
let partChar = boxChars[partWidth]; let partChar = boxChars[partWidth];
if (width - wholeWidth - 1 < 0) { if (width - wholeWidth - 1 < 0) {
partChar = ''; partChar = "";
} }
const fill = fullBox.repeat(wholeWidth); const fill = fullBox.repeat(wholeWidth);
const empty = ' '.repeat(width - wholeWidth - 1); const empty = " ".repeat(width - wholeWidth - 1);
return `${fill}${partChar}${empty}`; return `${fill}${partChar}${empty}`;
} }
@ -50,14 +54,16 @@ function getAsciiBar(progress: number, width: number) {
asciiChars.fill.repeat(Math.floor(progress * (width - 2))), asciiChars.fill.repeat(Math.floor(progress * (width - 2))),
asciiChars.empty.repeat(width - Math.ceil(progress * (width - 2))), asciiChars.empty.repeat(width - Math.ceil(progress * (width - 2))),
asciiChars.end, asciiChars.end,
].join(''); ].join("");
} }
/** A Progress Bar Style. Ascii is forced in non-unicode terminals. */ /** 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` */ /** 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 displayed to the right of the bar. */
text: string | ((props: ExtendedProps<Props>) => string); text: string | ((props: ExtendedProps<Props>) => string);
/** Text displayed to the left of the bar, if specified. */ /** Text displayed to the left of the bar, if specified. */
@ -82,17 +88,17 @@ export interface BarSpinnerOptions {
/** Sequence of frames for the spinner. */ /** Sequence of frames for the spinner. */
frames: string[]; frames: string[];
/** Color of the spinner. If set to `match` it will match the bar. */ /** Color of the spinner. If set to `match` it will match the bar. */
color: CustomLoggerColor | 'match'; color: CustomLoggerColor | "match";
} }
const defaultOptions = { const defaultOptions = {
beforeText: '', beforeText: "",
barWidth: 35, barWidth: 35,
barColor: 'rgb', barColor: "rgb",
barStyle: 'unicode', barStyle: "unicode",
spinner: { spinner: {
...defaultSpinnerOptions, ...defaultSpinnerOptions,
color: 'match', color: "match",
}, },
value: 0, value: 0,
total: 100, total: 100,
@ -105,12 +111,14 @@ type ExtendedProps<T> = T & {
progress: number; 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); #text: string | ((props: ExtendedProps<Props>) => string);
#beforeText: string | ((props: ExtendedProps<Props>) => string); #beforeText: string | ((props: ExtendedProps<Props>) => string);
#barWidth: number; #barWidth: number;
#barStyle: NonNullable<ProgressOptions['barStyle']>; #barStyle: NonNullable<ProgressOptions["barStyle"]>;
#spinnerColor: NonNullable<BarSpinnerOptions['color']>; #spinnerColor: NonNullable<BarSpinnerOptions["color"]>;
#spinnerFrames?: readonly string[]; #spinnerFrames?: readonly string[];
#props: Props; #props: Props;
#spinnerFPS: number; #spinnerFPS: number;
@ -121,7 +129,7 @@ export class Progress<Props extends Record<string, unknown> = Record<never, unkn
constructor(options: ProgressOptions<Props> | string) { constructor(options: ProgressOptions<Props> | string) {
super(); super();
if (typeof options === 'string') { if (typeof options === "string") {
options = { text: options }; options = { text: options };
} }
@ -138,8 +146,10 @@ export class Progress<Props extends Record<string, unknown> = Record<never, unkn
if (options.spinner !== null) { if (options.spinner !== null) {
this.fps = 15; this.fps = 15;
this.#spinnerFPS = options.spinner?.fps ?? defaultOptions.spinner.fps; this.#spinnerFPS = options.spinner?.fps ?? defaultOptions.spinner.fps;
this.#spinnerFrames = options.spinner?.frames ?? defaultOptions.spinner.frames; this.#spinnerFrames = options.spinner?.frames ??
this.#spinnerColor = options.spinner?.color ?? defaultOptions.spinner.color; defaultOptions.spinner.frames;
this.#spinnerColor = options.spinner?.color ??
defaultOptions.spinner.color;
} else { } else {
this.fps = 0; this.fps = 0;
this.#spinnerFPS = defaultOptions.spinner.fps; 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. */ /** Text displayed to the right of the bar. */
get text(): string { 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)) { 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. */ /** Text displayed to the left of the bar, if specified. */
get beforeText(): string { 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)) { set beforeText(value: string | (() => string)) {
@ -186,7 +200,7 @@ export class Progress<Props extends Record<string, unknown> = Record<never, unkn
this.redraw(); this.redraw();
} }
/** Current value of progress bar. */ /** Current value of progress bar. */
get value() { get value(): number {
return this.#value; 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. */ /** Total value of progress bar. When value === total, the bar is full. */
get total() { get total(): number {
return this.#total; return this.#total;
} }
@ -227,34 +241,37 @@ export class Progress<Props extends Record<string, unknown> = Record<never, unkn
let spinner; let spinner;
if (this.#spinnerFrames) { 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 spinner = this.#spinnerColor
? (this.#spinnerColor === 'match' ? (this.#spinnerColor === "match"
? chalk.rgb(...convertHSVtoRGB(hue, 0.8, 1)) ? chalk.rgb(...convertHSVtoRGB(hue, 0.8, 1))
: getColor(this.#spinnerColor))(this.#spinnerFrames[frame]) : getColor(this.#spinnerColor))(this.#spinnerFrames[frame])
: 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; const beforeText = this.beforeText;
return [ return [
spinner ? spinner + ' ' : '', spinner ? spinner + " " : "",
beforeText ? beforeText + ' ' : '', beforeText ? beforeText + " " : "",
barColor(getBar(progress, this.#barWidth)), barColor(getBar(progress, this.#barWidth)),
' ', " ",
this.text, this.text,
] ]
.filter(Boolean) .filter(Boolean)
.join(''); .join("");
} }
success(message?: string): void { override success(message?: string): void {
super.success(message ?? this.text); super.success(message ?? this.text);
} }
error(message?: string | Error): void { override error(message?: string | Error): void {
super.error(message ?? this.text); 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. */ /** Calls a function with a progress bar. */
export async function withProgress<Props extends Record<string, unknown>, T>( export async function withProgress<Props extends Record<string, unknown>, T>(
opts: WithProgressOptions<Props, T> | string, opts: WithProgressOptions<Props, T> | string,
fn: (bar: Progress<Props>) => Promise<T> fn: (bar: Progress<Props>) => Promise<T>,
): Promise<T>; ): Promise<T>;
export async function withProgress(opts: any, fn: any) { export async function withProgress(opts: any, fn: any) {
const bar = new Progress(opts); const bar = new Progress(opts);
@ -279,18 +296,18 @@ export async function withProgress(opts: any, fn: any) {
const result = await fn(bar); const result = await fn(bar);
bar.success( bar.success(
opts.successText opts.successText
? typeof opts.successText === 'function' ? typeof opts.successText === "function"
? opts.successText(result) ? opts.successText(result)
: opts.successText : opts.successText
: opts.text : opts.text
? typeof opts.text === 'function' ? typeof opts.text === "function" ? opts.text(bar.props) : opts.text
? opts.text(bar.props) : "Completed",
: opts.text
: 'Completed'
); );
} catch (error: any) { } catch (error: any) {
bar.error( bar.error(
typeof opts.failureText === 'function' ? opts.failureText(error) : opts.failureText ?? error typeof opts.failureText === "function"
? opts.failureText(error)
: (opts.failureText ?? error),
); );
throw error; throw error;
} }

View file

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

View file

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

View file

@ -1,17 +1,19 @@
import ansi from 'ansi-escapes'; import ansi from "ansi-escapes";
import { error, success } from './console.ts'; import { error, success } from "./console.ts";
import { flushStderr, writeToStderr } from './internal.ts'; import { flushStderr, writeToStderr } from "./internal.ts";
type Timer = ReturnType<typeof setInterval> & { unref?: () => void };
const widgets: Widget[] = []; const widgets: Widget[] = [];
let widgetLineCount = 0; let widgetLineCount = 0;
let widgetTimer: Timer | undefined; let widgetTimer: Timer | undefined;
let redrawingThisTick = false; let redrawingThisTick = false;
const kInternalUpdate = Symbol('internalUpdate'); const kInternalUpdate = Symbol("internalUpdate");
const kInternalGetText = Symbol('internalGetText'); const kInternalGetText = Symbol("internalGetText");
function onExit() { function onExit() {
errorAllWidgets('widget alive while process exiting'); errorAllWidgets("widget alive while process exiting");
writeToStderr(ansi.cursorShow); writeToStderr(ansi.cursorShow);
flushStderr(); flushStderr();
} }
@ -29,7 +31,7 @@ export abstract class Widget {
writeToStderr(ansi.cursorHide); writeToStderr(ansi.cursorHide);
widgetTimer = setInterval(redrawWidgetsNoWait, 1000 / 60); widgetTimer = setInterval(redrawWidgetsNoWait, 1000 / 60);
widgetTimer?.unref?.(); widgetTimer?.unref?.();
process.on('exit', onExit); process.on("exit", onExit);
} }
} }
@ -54,11 +56,11 @@ export abstract class Widget {
widgets.splice(index, 1); widgets.splice(index, 1);
redrawWidgetsSoon(); redrawWidgetsSoon();
if (finalMessage) { if (finalMessage) {
writeToStderr(finalMessage + '\n'); writeToStderr(finalMessage + "\n");
} }
if (widgets.length === 0) { if (widgets.length === 0) {
clearInterval(widgetTimer); clearInterval(widgetTimer);
process.removeListener('exit', onExit); process.removeListener("exit", onExit);
widgetTimer = undefined; widgetTimer = undefined;
writeToStderr(ansi.cursorShow); writeToStderr(ansi.cursorShow);
redrawWidgetsNoWait(); redrawWidgetsNoWait();
@ -72,23 +74,23 @@ export abstract class Widget {
} }
#nextUpdate = 0; #nextUpdate = 0;
#text = ''; #text = "";
#newlines = 0; #newlines = 0;
[kInternalUpdate](now: number) { [kInternalUpdate](now: number): boolean {
if (now > this.#nextUpdate) { if (now > this.#nextUpdate) {
this.#nextUpdate = this.fps === 0 ? Infinity : now + 1000 / this.fps; this.#nextUpdate = this.fps === 0 ? Infinity : now + 1000 / this.fps;
const text = this.format(now); const text = this.format(now);
if (text !== this.#text) { if (text !== this.#text) {
this.#text = text + '\n'; this.#text = text + "\n";
this.#newlines = text.split('\n').length; this.#newlines = text.split("\n").length;
} }
return true; return true;
} }
return false; return false;
} }
[kInternalGetText]() { [kInternalGetText](): string {
widgetLineCount += this.#newlines; widgetLineCount += this.#newlines;
return this.#text; return this.#text;
} }
@ -105,7 +107,7 @@ export abstract class Widget {
this.stop(); this.stop();
} }
get active() { get active(): boolean {
return widgets.includes(this); return widgets.includes(this);
} }
} }
@ -113,8 +115,10 @@ export abstract class Widget {
export function redrawWidgetsSoon() { export function redrawWidgetsSoon() {
if (widgetLineCount) { if (widgetLineCount) {
writeToStderr( writeToStderr(
'\u001B[?2026h' + "\u001B[?2026h" +
ansi.eraseLine + (ansi.cursorUp(1) + ansi.eraseLine).repeat(widgetLineCount) + '\r', ansi.eraseLine +
(ansi.cursorUp(1) + ansi.eraseLine).repeat(widgetLineCount) +
"\r",
true, true,
); );
widgetLineCount = 0; widgetLineCount = 0;
@ -126,18 +130,19 @@ export function redrawWidgetsSoon() {
function redrawWidgetsNoWait() { function redrawWidgetsNoWait() {
redrawingThisTick = false; redrawingThisTick = false;
const now = performance.now(); 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) { if (hasUpdate || widgetLineCount === 0) {
redrawWidgetsSoon(); redrawWidgetsSoon();
writeToStderr(widgets.map(widget => widget[kInternalGetText]()).join('')); writeToStderr(widgets.map((widget) => widget[kInternalGetText]()).join(""));
} }
flushStderr(); flushStderr();
} }
export function errorAllWidgets(reason: string) { export function errorAllWidgets(reason: string) {
for (const w of widgets) { for (const w of widgets) {
if ('text' in w) { if ("text" in w) {
w.error((w as any).text + ` (due to ${reason})`); w.error((w as any).text + ` (due to ${reason})`);
} else { } else {
w.stop(); w.stop();
@ -146,8 +151,8 @@ export function errorAllWidgets(reason: string) {
} }
/** Writes raw line of text without a prefix or filtering. */ /** Writes raw line of text without a prefix or filtering. */
export function writeLine(message = '') { export function writeLine(message = "") {
redrawWidgetsSoon(); redrawWidgetsSoon();
writeToStderr(message + '\n'); writeToStderr(message + "\n");
if (!redrawingThisTick) flushStderr(); if (!redrawingThisTick) flushStderr();
} }

View file

@ -4,15 +4,17 @@ import chalk from "chalk";
beforeEach(reset); beforeEach(reset);
test("built-in log levels", () => { test("built-in log levels", () => {
const console = require("@paperclover/console"); const console = require("@paperclover/console");
console.info("log"); console.info("log");
expect(getBuffer()).toEqual(`${chalk.blueBright.bold("info")} log\n`); expect(getBuffer()).toEqual(`${chalk.blueBright.bold("info")} log\n`);
reset(); reset();
console.warn("log"); console.warn("log");
expect(getBuffer()).toEqual(`${chalk.yellowBright.bold("warn")} ${chalk.yellowBright("log")}\n`); expect(getBuffer()).toEqual(
reset(); `${chalk.yellowBright.bold("warn")} ${chalk.yellowBright("log")}\n`,
console.error("log"); );
// Don't check the exact color formatting as it may vary reset();
expect(getBuffer()).toContain("✖"); console.error("log");
expect(getBuffer()).toContain("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 = export const isUnicodeSupported: boolean = process.platform === "win32"
process.platform === 'win32' ? Boolean(process.env.CI) ||
? Boolean(process.env.CI) || Boolean(process.env.WT_SESSION) || // Windows Terminal
Boolean(process.env.WT_SESSION) || // Windows Terminal process.env.ConEmuTask === "{cmd::Cmder}" || // ConEmu and cmder
process.env.ConEmuTask === '{cmd::Cmder}' || // ConEmu and cmder process.env.TERM_PROGRAM === "vscode" ||
process.env.TERM_PROGRAM === 'vscode' || process.env.TERM === "xterm-256color" ||
process.env.TERM === 'xterm-256color' || process.env.TERM === "alacritty" ||
process.env.TERM === 'alacritty' || process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm"
process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm' : process.env.TERM !== "linux";
: process.env.TERM !== 'linux';
export const errorSymbol = isUnicodeSupported ? '✖' : 'x'; export const errorSymbol = isUnicodeSupported ? "✖" : "x";
export const successSymbol = isUnicodeSupported ? '✔' : '√'; export const successSymbol = isUnicodeSupported ? "✔" : "√";
export const infoSymbol = isUnicodeSupported ? '' : 'i'; export const infoSymbol = isUnicodeSupported ? "" : "i";
export const warningSymbol = isUnicodeSupported ? '⚠' : '‼'; export const warningSymbol = isUnicodeSupported ? "⚠" : "‼";
let filters: string[] = []; let filters: string[] = [];
let filterGeneration = 0; let filterGeneration = 0;
export function setLogFilter(...newFilters: Array<string | string[]>) { export function setLogFilter(...newFilters: Array<string | string[]>) {
filters = newFilters.flat().map(filter => filter.toLowerCase()); filters = newFilters.flat().map((filter) => filter.toLowerCase());
filterGeneration++; filterGeneration++;
} }
export function isLogVisible(id: string, defaultVisibility = true) { export function isLogVisible(id: string, defaultVisibility = true): boolean {
for (const filter of filters) { for (const filter of filters) {
if (filter === '*') { if (filter === "*") {
defaultVisibility = true; defaultVisibility = true;
} else if (filter === '-*') { } else if (filter === "-*") {
defaultVisibility = false; defaultVisibility = false;
} else if (filter === id || id.startsWith(filter + ':')) { } else if (filter === id || id.startsWith(filter + ":")) {
defaultVisibility = true; defaultVisibility = true;
} else if (filter === '-' + id || id.startsWith('-' + filter + ':')) { } else if (filter === "-" + id || id.startsWith("-" + filter + ":")) {
defaultVisibility = false; defaultVisibility = false;
}
} }
return defaultVisibility; }
return defaultVisibility;
} }
if (process.env.DEBUG !== undefined) { if (process.env.DEBUG !== undefined) {
setLogFilter( setLogFilter(
String(process.env.DEBUG) String(process.env.DEBUG)
.split(',') .split(",")
.map(x => x.trim()) .map((x) => x.trim())
.map(x => (['1', 'true', 'all'].includes(x.toLowerCase()) ? '*' : x)) .map((x) => (["1", "true", "all"].includes(x.toLowerCase()) ? "*" : x)),
); );
} }
/** Taken from https://github.com/debug-js/debug/blob/d1616622e4d404863c5a98443f755b4006e971dc/src/node.js#L35. */ /** Taken from https://github.com/debug-js/debug/blob/d1616622e4d404863c5a98443f755b4006e971dc/src/node.js#L35. */
const debugColors = [ 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, 20,
78, 79, 80, 81, 92, 93, 98, 99, 112, 113, 128, 129, 134, 135, 148, 149, 160, 161, 162, 163, 164, 21,
165, 166, 167, 168, 169, 170, 171, 172, 173, 178, 179, 184, 185, 196, 197, 198, 199, 200, 201, 26,
202, 203, 204, 205, 206, 207, 208, 209, 214, 215, 220, 221, 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. */ /** Converts non string objects into a string the way Node.js' console.log does it. */
function stringify(...data: any[]) { function stringify(...data: any[]) {
return data return data
.map(obj => { .map((obj) => {
if (typeof obj === 'string') { if (typeof obj === "string") {
return obj; return obj;
} else if (obj instanceof Error) { } else if (obj instanceof Error) {
return formatErrorObj(obj); return formatErrorObj(obj);
} }
return inspect(obj, false, 4, true); return inspect(obj, false, 4, true);
}) })
.join(' '); .join(" ");
} }
/** /**
@ -74,56 +144,55 @@ function stringify(...data: any[]) {
* Taken from https://github.com/debug-js/debug/blob/master/src/common.js. * Taken from https://github.com/debug-js/debug/blob/master/src/common.js.
*/ */
function selectColor(namespace: string) { function selectColor(namespace: string) {
let hash = 0; let hash = 0;
for (let i = 0; i < namespace.length; i++) { for (let i = 0; i < namespace.length; i++) {
hash = (hash << 5) - hash + namespace.charCodeAt(i); hash = (hash << 5) - hash + namespace.charCodeAt(i);
hash |= 0; // Convert to 32bit integer hash |= 0; // Convert to 32bit integer
} }
return debugColors[Math.abs(hash) % debugColors.length]!; return debugColors[Math.abs(hash) % debugColors.length]!;
} }
const formatImplementation = { const formatImplementation = {
's': (data: StringLike) => String(data), s: (data: StringLike) => String(data),
'd': (data: number) => String(data), d: (data: number) => String(data),
'i': (data: number) => String(Math.floor(data)), i: (data: number) => String(Math.floor(data)),
'f': (data: number) => String(data), f: (data: number) => String(data),
'x': (data: number) => data.toString(16), x: (data: number) => data.toString(16),
'X': (data: number) => data.toString(16).toUpperCase(), X: (data: number) => data.toString(16).toUpperCase(),
'o': (data: any) => JSON.stringify(data), o: (data: any) => JSON.stringify(data),
'O': (data: any) => JSON.stringify(data, null, 2), O: (data: any) => JSON.stringify(data, null, 2),
'c': () => '', c: () => "",
'j': (data: any) => JSON.stringify(data), j: (data: any) => JSON.stringify(data),
}; };
function format(fmt: any, ...args: any[]) { function format(fmt: any, ...args: any[]) {
if (typeof fmt === 'string') { if (typeof fmt === "string") {
let index = 0; let index = 0;
const result = fmt.replace(/%[%sdifoxXcj]/g, match => { const result = fmt.replace(/%[%sdifoxXcj]/g, (match) => {
if (match === '%%') { if (match === "%%") {
return '%'; return "%";
} }
const arg = args[index++]; const arg = args[index++];
return (formatImplementation as any)[match[1]!](arg); return (formatImplementation as any)[match[1]!](arg);
}); });
if (index === 0 && args.length > 0) { if (index === 0 && args.length > 0) {
return result + ' ' + stringify(...args); return result + " " + stringify(...args);
}
return result;
} }
return stringify(fmt, ...args);
return result;
}
return stringify(fmt, ...args);
} }
const LogFunction = { /** @internal */
__proto__: Function.prototype, const LogFunction: {} = {
[Symbol.for('nodejs.util.inspect.custom')](depth: number, options: any) { __proto__: Function.prototype,
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");
},
}; };
/** /**
@ -138,108 +207,124 @@ const LogFunction = {
* - Using chalk or another formatter on the namespace name. * - Using chalk or another formatter on the namespace name.
*/ */
export function scoped( export function scoped(
name: string, name: string,
opts: CustomLoggerOptions | CustomLoggerColor = {} opts: CustomLoggerOptions | CustomLoggerColor = {},
): LogFunction { ): LogFunction {
if (typeof opts === 'string' || Array.isArray(opts) || typeof opts === 'number') { if (
opts = { color: opts }; 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) { if (fmt === undefined && args.length === 0) {
writeLine(); writeLine();
} else { } else {
writeLine( 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 }); }) as LogFunction;
let gen = filterGeneration; Object.setPrototypeOf(fn, LogFunction);
let visible = isLogVisible(id, !debug); Object.defineProperty(fn, "name", { value: id });
Object.defineProperty(fn, 'visible', { let gen = filterGeneration;
get: () => { let visible = isLogVisible(id, !debug);
if (gen !== filterGeneration) { Object.defineProperty(fn, "visible", {
gen = filterGeneration; get: () => {
visible = isLogVisible(id, !debug); if (gen !== filterGeneration) {
} gen = filterGeneration;
return visible; visible = isLogVisible(id, !debug);
} }
}); return visible;
return fn; },
});
return fn;
} }
/** Built in blue "info" logger. */ /** Built in blue "info" logger. */
export const info = /* @__PURE__ */ scoped('info', { export const info: LogFunction = /* @__PURE__ */ scoped("info", {
color: 'blueBright', color: "blueBright",
}); });
/** Built in yellow "warn" logger. */ /** Built in yellow "warn" logger. */
export const warn = /* @__PURE__ */ scoped('warn', { export const warn: LogFunction = /* @__PURE__ */ scoped("warn", {
color: 'yellowBright', color: "yellowBright",
coloredText: true, coloredText: true,
}); });
const _trace = /* @__PURE__ */ scoped('trace', { const _trace: LogFunction = /* @__PURE__ */ scoped("trace", {
color: 208, color: 208,
}); });
/** Built in orange "trace" logger. Prints a stack trace after the message. */ /** 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) => (
if (_trace.visible) { Object.defineProperty(trace, "visible", { get: () => _trace.visible }), trace
_trace(...(data.length === 0 ? [' '] : data)); ))(function trace(...data: any[]) {
writeLine(formatStackTrace(new Error()).split('\n').slice(1).join('\n')); if (_trace.visible) {
} _trace(...(data.length === 0 ? [" "] : data));
writeLine(formatStackTrace(new Error()).split("\n").slice(1).join("\n"));
}
}) as typeof _trace; }) as typeof _trace;
/** Built in red "error" logger, uses a unicode X instead of the word Error. */ /** Built in red "error" logger, uses a unicode X instead of the word Error. */
export const error = /* @__PURE__ */ scoped(errorSymbol, { export const error: LogFunction = /* @__PURE__ */ scoped(errorSymbol, {
id: 'error', id: "error",
color: 'redBright', color: "redBright",
coloredText: true, coloredText: true,
boldText: true, boldText: true,
}); });
/** Built in cyan "debug" logger. */ /** Built in cyan "debug" logger. */
export const debug = scoped('debug', { export const debug: LogFunction = scoped("debug", {
color: 'cyanBright', color: "cyanBright",
debug: true, debug: true,
}); });
/** Built in green "success" logger, uses a unicode Check instead of the word Success. */ /** Built in green "success" logger, uses a unicode Check instead of the word Success. */
export const success = /* @__PURE__ */ scoped(successSymbol, { export const success: LogFunction = /* @__PURE__ */ scoped(successSymbol, {
id: 'success', id: "success",
color: 'greenBright', color: "greenBright",
coloredText: true, coloredText: true,
boldText: true, boldText: true,
}); });
import chalk, { type ChalkInstance } from 'chalk'; import chalk, { type ChalkInstance } from "chalk";
import { inspect } from 'node:util'; import { inspect } from "node:util";
import { type LogFunction, type CustomLoggerColor, type CustomLoggerOptions, type StringLike, getColor } from './internal.ts'; import {
import { formatErrorObj, formatStackTrace } from './error.ts'; type CustomLoggerColor,
import stripAnsi from 'strip-ansi'; type CustomLoggerOptions,
import { writeLine } from './Widget.ts'; 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 { 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"; import chalk from "chalk";
test("formatErrorObj formats basic errors", () => { test("formatErrorObj formats basic errors", () => {
@ -16,9 +21,9 @@ test("formatErrorObj with PrintableError interface", () => {
const printableError = new Error("Custom message") as any; const printableError = new Error("Custom message") as any;
printableError.name = "CustomError"; printableError.name = "CustomError";
printableError.description = "This is a detailed description of the error"; printableError.description = "This is a detailed description of the error";
const formatted = formatErrorObj(printableError); const formatted = formatErrorObj(printableError);
// Should contain name, message and description // Should contain name, message and description
expect(formatted).toContain("CustomError: Custom message"); expect(formatted).toContain("CustomError: Custom message");
expect(formatted).toContain("This is a detailed description of the error"); 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", () => { test("formatErrorObj with hideName option", () => {
const printableError = new Error("Just the message") as any; const printableError = new Error("Just the message") as any;
printableError.hideName = true; printableError.hideName = true;
const formatted = formatErrorObj(printableError); const formatted = formatErrorObj(printableError);
// Should not contain "Error:" // Should not contain "Error:"
expect(formatted).not.toContain("Error:"); expect(formatted).not.toContain("Error:");
// Don't check the exact output since the stack trace might vary // 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", () => { test("formatErrorObj with hideStack option", () => {
const printableError = new Error("No stack trace") as any; const printableError = new Error("No stack trace") as any;
printableError.hideStack = true; printableError.hideStack = true;
const formatted = formatErrorObj(printableError); const formatted = formatErrorObj(printableError);
// Should contain message but not stack trace // Should contain message but not stack trace
expect(formatted).toContain("Error: No stack trace"); expect(formatted).toContain("Error: No stack trace");
expect(formatted).not.toContain("at <anonymous>"); expect(formatted).not.toContain("at <anonymous>");
@ -50,7 +55,7 @@ test("formatErrorObj with hideStack option", () => {
test("formatStackTrace with V8 style stack", () => { test("formatStackTrace with V8 style stack", () => {
const error = new Error("Stack trace test"); const error = new Error("Stack trace test");
const formatted = formatStackTrace(error); const formatted = formatStackTrace(error);
// Should format the stack trace // Should format the stack trace
expect(formatted).toContain("at "); expect(formatted).toContain("at ");
// May not have color codes in test environment // May not have color codes in test environment
@ -59,9 +64,9 @@ test("formatStackTrace with V8 style stack", () => {
test("formatStackTrace with empty stack", () => { test("formatStackTrace with empty stack", () => {
const error = new Error("No stack"); const error = new Error("No stack");
error.stack = undefined; error.stack = undefined;
const formatted = formatStackTrace(error); const formatted = formatStackTrace(error);
// Should handle undefined stack // Should handle undefined stack
expect(formatted).toEqual(""); expect(formatted).toEqual("");
}); });
@ -70,7 +75,7 @@ test("platformSimplifyErrorPath with cwd", () => {
const cwd = process.cwd(); const cwd = process.cwd();
const fullPath = `${cwd}/src/error.ts`; const fullPath = `${cwd}/src/error.ts`;
const simplified = platformSimplifyErrorPath(fullPath); const simplified = platformSimplifyErrorPath(fullPath);
// Should convert to a relative path // Should convert to a relative path
expect(simplified).toEqual("./src/error.ts"); expect(simplified).toEqual("./src/error.ts");
}); });
@ -78,7 +83,7 @@ test("platformSimplifyErrorPath with cwd", () => {
test("platformSimplifyErrorPath with external path", () => { test("platformSimplifyErrorPath with external path", () => {
const externalPath = "/usr/local/lib/node_modules/module.js"; const externalPath = "/usr/local/lib/node_modules/module.js";
const simplified = platformSimplifyErrorPath(externalPath); const simplified = platformSimplifyErrorPath(externalPath);
// Should keep external paths unchanged // Should keep external paths unchanged
expect(simplified).toEqual(externalPath); expect(simplified).toEqual(externalPath);
}); });
@ -86,18 +91,22 @@ test("platformSimplifyErrorPath with external path", () => {
test("CLIError class", () => { test("CLIError class", () => {
const cliError = new CLIError( const cliError = new CLIError(
"Command failed", "Command failed",
"Try running with --help for available options" "Try running with --help for available options",
); );
expect(cliError.message).toEqual("Command failed"); 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.hideStack).toEqual(true);
expect(cliError.hideName).toEqual(true); expect(cliError.hideName).toEqual(true);
const formatted = formatErrorObj(cliError); const formatted = formatErrorObj(cliError);
// Should format according to the PrintableError interface // 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("CLIError");
expect(formatted).not.toContain("at "); expect(formatted).not.toContain("at ");
}); });
@ -109,9 +118,9 @@ test("pretty printing of objects in stack traces", () => {
JSON.parse("{invalid}"); JSON.parse("{invalid}");
} catch (error) { } catch (error) {
const formatted = formatErrorObj(error as Error); const formatted = formatErrorObj(error as Error);
// Should format the error message // Should format the error message
expect(formatted).toContain("SyntaxError"); expect(formatted).toContain("SyntaxError");
expect(formatted).toContain("at "); expect(formatted).toContain("at ");
} }
}); });

View file

@ -1,11 +1,11 @@
import chalk from 'chalk'; import chalk from "chalk";
import path from 'node:path'; import path from "node:path";
import { isBuiltin } from 'node:module'; import { isBuiltin } from "node:module";
export function platformSimplifyErrorPath(filepath: string) { export function platformSimplifyErrorPath(filepath: string): string {
const cwd = process.cwd(); const cwd = process.cwd();
if (filepath.startsWith(cwd)) { if (filepath.startsWith(cwd)) {
return '.' + filepath.slice(cwd.length); return "." + filepath.slice(cwd.length);
} }
return filepath; return filepath;
} }
@ -25,60 +25,65 @@ export interface PrintableError extends Error {
} }
/** Utility function we use internally for formatting the stack trace of an 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) { 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) const parsed = err.stack.startsWith(v8firstLine)
? err.stack ? err.stack
.slice(v8firstLine.length) .slice(v8firstLine.length)
.split('\n') .split("\n")
.map(line => { .map((line) => {
const match = /at (.*) \((.*):(\d+):(\d+)\)/.exec(line); const match = /at (.*) \((.*):(\d+):(\d+)\)/.exec(line);
if (!match) { if (!match) {
const match2 = /at (.*):(\d+):(\d+)/.exec(line); const match2 = /at (.*):(\d+):(\d+)/.exec(line);
if (match2) { if (match2) {
return { return {
method: '<top level>', method: "<top level>",
file: match2[1], file: match2[1],
line: match2[2], line: match2[2],
column: match2[3], column: match2[3],
}; };
} }
return { method: '<unknown>', file: null, line: null, column: null }; return {
method: "<unknown>",
file: null,
line: null,
column: null,
};
} }
return { return {
method: match[1], method: match[1],
file: match[2], file: match[2],
line: parseInt(match[3] ?? '0', 10), line: parseInt(match[3] ?? "0", 10),
column: parseInt(match[4] ?? '0', 10), column: parseInt(match[4] ?? "0", 10),
native: line.includes('[native code]'), native: line.includes("[native code]"),
}; };
}) })
: err.stack.split('\n').map(line => { : err.stack.split("\n").map((line) => {
const at = line.indexOf('@'); const at = line.indexOf("@");
const method = line.slice(0, at); const method = line.slice(0, at);
const file = line.slice(at + 1); const file = line.slice(at + 1);
const fileSplit = /^(.*?):(\d+):(\d+)$/.exec(file); const fileSplit = /^(.*?):(\d+):(\d+)$/.exec(file);
return { return {
method: (['module code'].includes(method) ? '' : method) || '', method: (["module code"].includes(method) ? "" : method) || "",
file: fileSplit ? platformSimplifyErrorPath(fileSplit[1] ?? '') : null, file: fileSplit ? platformSimplifyErrorPath(fileSplit[1] ?? "") : null,
line: fileSplit ? parseInt(fileSplit[2] ?? '0', 10) : null, line: fileSplit ? parseInt(fileSplit[2] ?? "0", 10) : null,
column: fileSplit ? parseInt(fileSplit[3] ?? '0', 10) : null, column: fileSplit ? parseInt(fileSplit[3] ?? "0", 10) : null,
native: file === '[native code]', native: file === "[native code]",
}; };
}); });
const nodeModuleJobIndex = parsed.findIndex( 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) { if (nodeModuleJobIndex !== -1) {
parsed.splice(nodeModuleJobIndex, Infinity); parsed.splice(nodeModuleJobIndex, Infinity);
} }
parsed.reverse(); parsed.reverse();
const sliceAt = parsed.findIndex(line => !line.native); const sliceAt = parsed.findIndex((line) => !line.native);
if (sliceAt !== -1) { if (sliceAt !== -1) {
// remove the first native lines // remove the first native lines
parsed.splice(0, sliceAt); parsed.splice(0, sliceAt);
@ -88,12 +93,11 @@ export function formatStackTrace(err: Error) {
return parsed return parsed
.map(({ method, file, line, column, native }) => { .map(({ method, file, line, column, native }) => {
function getColoredDirname(filename: string) { function getColoredDirname(filename: string) {
const dirname = const dirname = process.platform === "win32"
process.platform === 'win32' ? path.dirname(filename).replace(/^file:\/\/\//g, "")
? path.dirname(filename).replace(/^file:\/\/\//g, '') : path.dirname(filename).replace(/^file:\/\//g, "") + path.sep;
: path.dirname(filename).replace(/^file:\/\//g, '') + path.sep;
if (dirname === '/' || dirname === './') { if (dirname === "/" || dirname === "./") {
return dirname; return dirname;
} }
return chalk.cyan(dirname); return chalk.cyan(dirname);
@ -102,35 +106,36 @@ export function formatStackTrace(err: Error) {
const source = native const source = native
? `[native code]` ? `[native code]`
: file : file
? isBuiltin(file) ? isBuiltin(file) ? `(${chalk.magenta(file)})` : [
? `(${chalk.magenta(file)})` "(",
: [ getColoredDirname(file),
'(', // Leave the first slash on linux.
getColoredDirname(file), chalk.green(path.basename(file)),
// Leave the first slash on linux. ":",
chalk.green(path.basename(file)), line + ":" + column,
':', ")",
line + ':' + column, ].join("")
')', : "<unknown>";
].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. */ /** Formats the given error as a full log string. */
export function formatErrorObj(err: Error | PrintableError) { export function formatErrorObj(err: Error | PrintableError): string {
const { name, message, description, hideStack, hideName, stack } = err as PrintableError; const { name, message, description, hideStack, hideName, stack } =
err as PrintableError;
return [ return [
hideName ? '' : (name ?? 'Error') + ': ', hideName ? "" : (name ?? "Error") + ": ",
message ?? 'Unknown error', message ?? "Unknown error",
description ? '\n' + description : '', description ? "\n" + description : "",
hideStack || !stack ? '' : '\n' + chalk.reset(formatStackTrace(err)), hideStack || !stack ? "" : "\n" + chalk.reset(formatStackTrace(err)),
description || (!hideStack && stack) ? '\n' : '', description || (!hideStack && stack) ? "\n" : "",
].join(''); ].join("");
} }
/** /**
@ -158,15 +163,15 @@ export class CLIError extends Error implements PrintableError {
constructor(message: string, description: string) { constructor(message: string, description: string) {
super(message); super(message);
this.name = 'CLIError'; this.name = "CLIError";
this.description = description; this.description = description;
} }
get hideStack() { get hideStack(): boolean {
return true; return true;
} }
get hideName() { get hideName(): boolean {
return true; 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"; import { spawn } from "child_process";
// We need to use a subprocess to test injection since it happens immediately on import // We need to use a subprocess to test injection since it happens immediately on import
// and would affect our test environment // 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) => { return new Promise((resolve, reject) => {
// Merge the current process.env with any additional env variables // Merge the current process.env with any additional env variables
const environment = { ...process.env, ...env }; const environment = { ...process.env, ...env };
const subprocess = spawn("bun", ["src/test/subprocess.ts", testType], { const subprocess = spawn("bun", ["src/test/subprocess.ts", testType], {
env: environment env: environment,
}); });
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
subprocess.stdout.on("data", (data) => { subprocess.stdout.on("data", (data) => {
stdout += data.toString(); stdout += data.toString();
}); });
subprocess.stderr.on("data", (data) => { subprocess.stderr.on("data", (data) => {
stderr += data.toString(); stderr += data.toString();
}); });
subprocess.on("error", (error) => { subprocess.on("error", (error) => {
reject(error); reject(error);
}); });
subprocess.on("close", (code) => { subprocess.on("close", (code) => {
if (code !== 0) { if (code !== 0) {
reject(new Error(`Subprocess exited with code ${code}: ${stderr}`)); 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 () => { test("basic injection is applied correctly", async () => {
// Enable debug logs with DEBUG=1 and force color with FORCE_COLOR // 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", DEBUG: "1",
FORCE_COLOR: "1" FORCE_COLOR: "1",
}); });
// Check that console methods are correctly injected // Check that console methods are correctly injected
expect(output).toContain("INJECTED_LOG"); expect(output).toContain("INJECTED_LOG");
expect(output).toContain("INJECTED_INFO"); expect(output).toContain("INJECTED_INFO");
expect(output).toContain("INJECTED_WARN"); expect(output).toContain("INJECTED_WARN");
expect(output).toContain("INJECTED_ERROR"); expect(output).toContain("INJECTED_ERROR");
expect(output).toContain("INJECTED_DEBUG"); expect(output).toContain("INJECTED_DEBUG");
// Assert should only output when condition is false // Assert should only output when condition is false
expect(output).toContain("INJECTED_ASSERT"); expect(output).toContain("INJECTED_ASSERT");
expect(output).not.toContain("This should not appear"); 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 () => { test("timer functions work correctly", async () => {
const output = await runSubprocess("time-functions"); const output = await runSubprocess("time-functions");
// Check timer functions // Check timer functions
expect(output).toContain("TIMER_LABEL"); expect(output).toContain("TIMER_LABEL");
expect(output).toContain("ms]"); // Time output should include milliseconds expect(output).toContain("ms]"); // Time output should include milliseconds
// Warning for non-existent timer // Warning for non-existent timer
expect(output).toContain("does not exist"); expect(output).toContain("does not exist");
}); });
test("count functions work correctly", async () => { test("count functions work correctly", async () => {
const output = await runSubprocess("count-functions"); const output = await runSubprocess("count-functions");
// Check counter functions // Check counter functions
expect(output).toContain("COUNTER_LABEL: 1"); expect(output).toContain("COUNTER_LABEL: 1");
expect(output).toContain("COUNTER_LABEL: 2"); expect(output).toContain("COUNTER_LABEL: 2");
@ -79,9 +82,9 @@ test("count functions work correctly", async () => {
test("environment variables affect injection", async () => { test("environment variables affect injection", async () => {
const output = await runSubprocess("disabled-injection", { NO_COLOR: "1" }); const output = await runSubprocess("disabled-injection", { NO_COLOR: "1" });
// When NO_COLOR is set, output should still have the message // When NO_COLOR is set, output should still have the message
expect(output).toContain("DISABLED_COLOR_LOG"); expect(output).toContain("DISABLED_COLOR_LOG");
// Note: Chalk may still output color codes even with NO_COLOR=1 in some environments // 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 // So we're not checking for absence of color codes here
}); });

View file

@ -1,7 +1,7 @@
import chalk from 'chalk'; import chalk from "chalk";
import { debug, error, info, trace, warn } from './console.ts'; import { debug, error, info, trace, warn } from "./console.ts";
import { Spinner } from './Spinner.ts'; import { Spinner } from "./Spinner.ts";
import { errorAllWidgets, writeLine } from './Widget.ts'; import { errorAllWidgets, writeLine } from "./Widget.ts";
// Basic Logging Functions // Basic Logging Functions
console.log = info; console.log = info;
@ -38,7 +38,9 @@ console.timeEnd = (label: string) => {
} }
const { start, spinner } = timers.get(label)!; const { start, spinner } = timers.get(label)!;
timers.delete(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) => { console.timeLog = (label: string) => {
if (!timers.has(label)) { if (!timers.has(label)) {
@ -46,7 +48,9 @@ console.timeLog = (label: string) => {
return; return;
} }
const { start } = timers.get(label)!; 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>(); const counters = new Map<string, number>();
@ -61,20 +65,22 @@ console.countReset = (label: string) => {
console.trace = trace; console.trace = trace;
process.on('uncaughtException', (exception: any) => { process.on("uncaughtException", (exception: any) => {
errorAllWidgets('uncaught exception'); errorAllWidgets("uncaught exception");
error(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.exit(1);
}); });
process.on('unhandledRejection', (reason: any) => { process.on("unhandledRejection", (reason: any) => {
errorAllWidgets('unhandled rejection'); errorAllWidgets("unhandled rejection");
error(reason); error(reason);
writeLine( 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); process.exit(1);
}); });

View file

@ -1,164 +1,170 @@
import chalk, { type ChalkInstance } from 'chalk'; import chalk, { type ChalkInstance } from "chalk";
import { inspect } from 'node:util'; import { inspect } from "node:util";
export function convertHSVtoRGB(h: number, s: number, v: number): [number, number, number] { export function convertHSVtoRGB(
let r, g, b; h: number,
const i = Math.floor(h * 6); s: number,
const f = h * 6 - i; v: number,
const p = v * (1 - s); ): [number, number, number] {
const q = v * (1 - f * s); let r, g, b;
const t = v * (1 - (1 - f) * s); const i = Math.floor(h * 6);
switch (i % 6) { const f = h * 6 - i;
case 0: const p = v * (1 - s);
(r = v), (g = t), (b = p); const q = v * (1 - f * s);
break; const t = v * (1 - (1 - f) * s);
case 1: switch (i % 6) {
(r = q), (g = v), (b = p); case 0:
break; (r = v), (g = t), (b = p);
case 2: break;
(r = p), (g = v), (b = t); case 1:
break; (r = q), (g = v), (b = p);
case 3: break;
(r = p), (g = q), (b = v); case 2:
break; (r = p), (g = v), (b = t);
case 4: break;
(r = t), (g = p), (b = v); case 3:
break; (r = p), (g = q), (b = v);
case 5: break;
(r = v), (g = p), (b = q); case 4:
break; (r = t), (g = p), (b = v);
default: break;
return [0, 0, 0]; case 5:
} (r = v), (g = p), (b = q);
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; 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. */ /** Converts non string objects into a string the way Node.js' console.log does it. */
export function stringify(...data: any[]) { 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 bufferNeedsUnfreeze = false;
let exiting = false; let exiting = false;
export function writeToStderr(data: string, needsUnfreeze = false) { export function writeToStderr(data: string, needsUnfreeze = false) {
buffer += data; buffer += data;
if (exiting) flushStderr(); if (exiting) flushStderr();
if (needsUnfreeze) bufferNeedsUnfreeze = true; if (needsUnfreeze) bufferNeedsUnfreeze = true;
} }
const stderr = process.stderr; const stderr = process.stderr;
export function flushStderr() { export function flushStderr() {
if (buffer) { if (buffer) {
if (bufferNeedsUnfreeze) { if (bufferNeedsUnfreeze) {
buffer += '\u001B[?2026l'; buffer += "\u001B[?2026l";
bufferNeedsUnfreeze = false; bufferNeedsUnfreeze = false;
}
stderr.write(buffer);
buffer = '';
} }
stderr.write(buffer);
buffer = "";
}
} }
process.on('exit', () => { process.on("exit", () => {
exiting = true; exiting = true;
buffer += '\x1b[0;39;49m'; buffer += "\x1b[0;39;49m";
flushStderr(); flushStderr();
}); });
export type CustomLoggerColor = export type CustomLoggerColor =
| 'black' | "black"
| 'red' | "red"
| 'green' | "green"
| 'yellow' | "yellow"
| 'blue' | "blue"
| 'magenta' | "magenta"
| 'cyan' | "cyan"
| 'white' | "white"
| 'gray' | "gray"
| 'grey' | "grey"
| 'blackBright' | "blackBright"
| 'redBright' | "redBright"
| 'greenBright' | "greenBright"
| 'yellowBright' | "yellowBright"
| 'blueBright' | "blueBright"
| 'magentaBright' | "magentaBright"
| 'cyanBright' | "cyanBright"
| 'whiteBright' | "whiteBright"
| `#${string}` | `#${string}`
| number | number
| [number, number, number]; | [number, number, number];
export interface CustomLoggerOptions { export interface CustomLoggerOptions {
id?: string; id?: string;
color?: CustomLoggerColor; color?: CustomLoggerColor;
coloredText?: boolean; coloredText?: boolean;
boldText?: boolean; boldText?: boolean;
level?: number; level?: number;
debug?: boolean; debug?: boolean;
} }
/** Matches `string`, `number`, and other objects with a `.toString()` method. */ /** Matches `string`, `number`, and other objects with a `.toString()` method. */
export interface StringLike { export interface StringLike {
toString(): string; toString(): string;
} }
export interface FormatStringArgs { export interface FormatStringArgs {
'%s': StringLike | null | undefined; "%s": StringLike | null | undefined;
'%d': number | null | undefined; "%d": number | null | undefined;
'%i': number | null | undefined; "%i": number | null | undefined;
'%f': number | null | undefined; "%f": number | null | undefined;
'%x': number | null | undefined; "%x": number | null | undefined;
'%X': number | null | undefined; "%X": number | null | undefined;
'%o': any; "%o": any;
'%O': any; "%O": any;
'%c': string | null | undefined; "%c": string | null | undefined;
'%j': any; "%j": any;
} }
export type ProcessFormatString<S> = S extends `${string}%${infer K}${infer B}` export type ProcessFormatString<S> = S extends `${string}%${infer K}${infer B}`
? `%${K}` extends keyof FormatStringArgs ? `%${K}` extends keyof FormatStringArgs
? [FormatStringArgs[`%${K}`], ...ProcessFormatString<B>] ? [FormatStringArgs[`%${K}`], ...ProcessFormatString<B>]
: ProcessFormatString<B> : ProcessFormatString<B>
: []; : [];
export type LogData = string | number | boolean | object | null | undefined; export type LogData = string | number | boolean | object | null | undefined;
export interface LogFunction { export interface LogFunction {
/** /**
* Writes data to the log. The first argument can be a printf-style format string, or usage * 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 * similar to `console.log`. Handles formatting objects including `Error` objects with pretty
* colorized stack traces. * colorized stack traces.
* *
* List of formatters: * List of formatters:
* *
* - %s - String. * - %s - String.
* - %d, %f - Number. * - %d, %f - Number.
* - %i - Integer. * - %i - Integer.
* - %x - Hex. * - %x - Hex.
* - %X - Hex (uppercase) * - %X - Hex (uppercase)
* - %o - Object. * - %o - Object.
* - %O - Object (pretty printed). * - %O - Object (pretty printed).
* - %j - JSON. * - %j - JSON.
*/ */
<S extends LogData>(data?: S, ...a: ProcessFormatString<S>): void; <S extends LogData>(data?: S, ...a: ProcessFormatString<S>): void;
/** Calling a logger function with no arguments prints a blank line. */ /** Calling a logger function with no arguments prints a blank line. */
(): void; (): void;
visible: boolean; visible: boolean;
name: string; name: string;
} }
export function getColor(color: CustomLoggerColor): ChalkInstance { export function getColor(color: CustomLoggerColor): ChalkInstance {
if (typeof color === 'string') { if (typeof color === "string") {
if (color in chalk) { if (color in chalk) {
return (chalk as any)[color]; 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); return chalk.hex(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); 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 // First argument tells what to test
const testType = process.argv[2]; const testType = process.argv[2];
if (testType === 'basic-injection') { if (testType === "basic-injection") {
// Import and test basic injection // Import and test basic injection
import('@paperclover/console/inject').then(() => { import("@paperclover/console/inject").then(() => {
// Check that console methods are replaced // Check that console methods are replaced
console.log('INJECTED_LOG'); console.log("INJECTED_LOG");
console.info('INJECTED_INFO'); console.info("INJECTED_INFO");
console.warn('INJECTED_WARN'); console.warn("INJECTED_WARN");
console.error('INJECTED_ERROR'); console.error("INJECTED_ERROR");
console.debug('INJECTED_DEBUG'); console.debug("INJECTED_DEBUG");
// Assert // Assert
console.assert(true, 'This should not appear'); console.assert(true, "This should not appear");
console.assert(false, 'INJECTED_ASSERT'); console.assert(false, "INJECTED_ASSERT");
// Exit when done // Exit when done
setTimeout(() => process.exit(0), 100); setTimeout(() => process.exit(0), 100);
}); });
} else if (testType === 'time-functions') { } else if (testType === "time-functions") {
// Import and test timing functions // Import and test timing functions
import('@paperclover/console/inject').then(() => { import("@paperclover/console/inject").then(() => {
console.time('TIMER_LABEL'); console.time("TIMER_LABEL");
console.timeLog('TIMER_LABEL'); console.timeLog("TIMER_LABEL");
console.timeEnd('TIMER_LABEL'); console.timeEnd("TIMER_LABEL");
// Test invalid timer // Test invalid timer
console.timeEnd('NONEXISTENT_TIMER'); console.timeEnd("NONEXISTENT_TIMER");
// Exit when done // Exit when done
setTimeout(() => process.exit(0), 100); setTimeout(() => process.exit(0), 100);
}); });
} else if (testType === 'count-functions') { } else if (testType === "count-functions") {
// Import and test count functions // Import and test count functions
import('@paperclover/console/inject').then(() => { import("@paperclover/console/inject").then(() => {
console.count('COUNTER_LABEL'); console.count("COUNTER_LABEL");
console.count('COUNTER_LABEL'); console.count("COUNTER_LABEL");
console.countReset('COUNTER_LABEL'); console.countReset("COUNTER_LABEL");
console.count('COUNTER_LABEL'); console.count("COUNTER_LABEL");
// Exit when done // Exit when done
setTimeout(() => process.exit(0), 100); setTimeout(() => process.exit(0), 100);
}); });
} else if (testType === 'disabled-injection') { } else if (testType === "disabled-injection") {
// NO_COLOR should be passed via spawn options // NO_COLOR should be passed via spawn options
import('@paperclover/console/inject').then(() => { import("@paperclover/console/inject").then(() => {
console.log('DISABLED_COLOR_LOG'); console.log("DISABLED_COLOR_LOG");
// Exit when done // Exit when done
setTimeout(() => process.exit(0), 100); setTimeout(() => process.exit(0), 100);
}); });
} }

View file

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