use ai to write tests

This commit is contained in:
chloe caruso 2025-03-14 23:22:22 -07:00
parent 89c29a54ee
commit b744837ea6
11 changed files with 1017 additions and 16 deletions

336
src/Progress.test.ts Normal file
View file

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

View file

@ -217,7 +217,7 @@ export class Progress<Props extends Record<string, unknown> = Record<never, unkn
this.redraw(); this.redraw();
} }
protected format(now: number): string { format(now: number): string {
const progress = this.#total === 0 ? 1 : this.#value / this.#total; const progress = this.#total === 0 ? 1 : this.#value / this.#total;
const hue = Math.min(Math.max(progress, 0), 1) / 3; const hue = Math.min(Math.max(progress, 0), 1) / 3;

288
src/Spinner.test.ts Normal file
View file

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

View file

@ -1,4 +1,3 @@
import chalk, { type ChalkInstance } from 'chalk';
import { Widget } from './Widget.ts'; import { Widget } from './Widget.ts';
import { getColor, type CustomLoggerColor } from './internal.ts'; import { getColor, type CustomLoggerColor } from './internal.ts';
@ -23,9 +22,9 @@ export const defaultSpinnerOptions = {
} 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[];
#text: string | ((props: Props) => string); #text: string | ((props: Props) => string);
#color: ((text: string) => string) | null; #color: ((text: string) => string) | null;
#frames: readonly string[];
#props: Props; #props: Props;
fps: number; fps: number;
@ -37,7 +36,7 @@ export class Spinner<Props extends Record<string, unknown> = Record<string, unkn
this.#text = options.text ?? defaultSpinnerOptions.text; this.#text = options.text ?? defaultSpinnerOptions.text;
const color = options.color ?? defaultSpinnerOptions.color; const color = options.color ?? defaultSpinnerOptions.color;
this.#color = color === false ? null : getColor(color); this.#color = color === false ? null : getColor(color);
this.#frames = options.frames ?? defaultSpinnerOptions.frames; this.frames = options.frames ?? defaultSpinnerOptions.frames;
this.fps = options.fps ?? defaultSpinnerOptions.fps; this.fps = options.fps ?? defaultSpinnerOptions.fps;
this.#props = options.props ?? ({} as Props); this.#props = options.props ?? ({} as Props);
} }
@ -83,8 +82,8 @@ 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.#color ? this.#color(frameText) : frameText) +
' ' + ' ' +
@ -130,6 +129,7 @@ export async function withSpinner(opts: any, fn: any) {
: 'Completed' : 'Completed'
); );
} }
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

View file

@ -10,15 +10,6 @@ let redrawingThisTick = false;
const kInternalUpdate = Symbol('internalUpdate'); const kInternalUpdate = Symbol('internalUpdate');
const kInternalGetText = Symbol('internalGetText'); const kInternalGetText = Symbol('internalGetText');
export interface Key {
sequence?: string;
text?: string;
name?: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
}
function onExit() { function onExit() {
errorAllWidgets('widget alive while process exiting'); errorAllWidgets('widget alive while process exiting');
writeToStderr(ansi.cursorShow); writeToStderr(ansi.cursorShow);

18
src/console.test.ts Normal file
View file

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

117
src/error.test.ts Normal file
View file

@ -0,0 +1,117 @@
import { expect, test } from "bun:test";
import { formatErrorObj, formatStackTrace, CLIError, platformSimplifyErrorPath } from "./error.ts";
import chalk from "chalk";
test("formatErrorObj formats basic errors", () => {
const error = new Error("Something went wrong");
const formatted = formatErrorObj(error);
// Basic error formatting should contain the error name and message
expect(formatted).toContain("Error: Something went wrong");
// Should contain a stack trace
expect(formatted).toContain("at ");
});
test("formatErrorObj with PrintableError interface", () => {
const printableError = new Error("Custom message") as any;
printableError.name = "CustomError";
printableError.description = "This is a detailed description of the error";
const formatted = formatErrorObj(printableError);
// Should contain name, message and description
expect(formatted).toContain("CustomError: Custom message");
expect(formatted).toContain("This is a detailed description of the error");
expect(formatted).toContain("at ");
});
test("formatErrorObj with hideName option", () => {
const printableError = new Error("Just the message") as any;
printableError.hideName = true;
const formatted = formatErrorObj(printableError);
// Should not contain "Error:"
expect(formatted).not.toContain("Error:");
// Don't check the exact output since the stack trace might vary
});
test("formatErrorObj with hideStack option", () => {
const printableError = new Error("No stack trace") as any;
printableError.hideStack = true;
const formatted = formatErrorObj(printableError);
// Should contain message but not stack trace
expect(formatted).toContain("Error: No stack trace");
expect(formatted).not.toContain("at <anonymous>");
});
test("formatStackTrace with V8 style stack", () => {
const error = new Error("Stack trace test");
const formatted = formatStackTrace(error);
// Should format the stack trace
expect(formatted).toContain("at ");
// May not have color codes in test environment
});
test("formatStackTrace with empty stack", () => {
const error = new Error("No stack");
error.stack = undefined;
const formatted = formatStackTrace(error);
// Should handle undefined stack
expect(formatted).toEqual("");
});
test("platformSimplifyErrorPath with cwd", () => {
const cwd = process.cwd();
const fullPath = `${cwd}/src/error.ts`;
const simplified = platformSimplifyErrorPath(fullPath);
// Should convert to a relative path
expect(simplified).toEqual("./src/error.ts");
});
test("platformSimplifyErrorPath with external path", () => {
const externalPath = "/usr/local/lib/node_modules/module.js";
const simplified = platformSimplifyErrorPath(externalPath);
// Should keep external paths unchanged
expect(simplified).toEqual(externalPath);
});
test("CLIError class", () => {
const cliError = new CLIError(
"Command failed",
"Try running with --help for available options"
);
expect(cliError.message).toEqual("Command failed");
expect(cliError.description).toEqual("Try running with --help for available options");
expect(cliError.hideStack).toEqual(true);
expect(cliError.hideName).toEqual(true);
const formatted = formatErrorObj(cliError);
// Should format according to the PrintableError interface
expect(formatted).toEqual("Command failed\nTry running with --help for available options\n");
expect(formatted).not.toContain("CLIError");
expect(formatted).not.toContain("at ");
});
test("pretty printing of objects in stack traces", () => {
try {
// Create an error with a complex object
const complexObj = { a: 1, b: { c: 2 }, d: [1, 2, 3] };
JSON.parse("{invalid}");
} catch (error) {
const formatted = formatErrorObj(error as Error);
// Should format the error message
expect(formatted).toContain("SyntaxError");
expect(formatted).toContain("at ");
}
});

87
src/inject.test.ts Normal file
View file

@ -0,0 +1,87 @@
import { beforeEach, expect, test, mock } from "bun:test";
import { spawn } from "child_process";
// We need to use a subprocess to test injection since it happens immediately on import
// and would affect our test environment
function runSubprocess(testType: string, env: Record<string, string> = {}): Promise<string> {
return new Promise((resolve, reject) => {
// Merge the current process.env with any additional env variables
const environment = { ...process.env, ...env };
const subprocess = spawn("bun", ["src/test/subprocess.ts", testType], {
env: environment
});
let stdout = "";
let stderr = "";
subprocess.stdout.on("data", (data) => {
stdout += data.toString();
});
subprocess.stderr.on("data", (data) => {
stderr += data.toString();
});
subprocess.on("error", (error) => {
reject(error);
});
subprocess.on("close", (code) => {
if (code !== 0) {
reject(new Error(`Subprocess exited with code ${code}: ${stderr}`));
} else {
resolve(stdout + stderr); // Combine stdout and stderr since our console outputs go to stderr
}
});
});
}
test("basic injection is applied correctly", async () => {
// Enable debug logs with DEBUG=1 and force color with FORCE_COLOR
const output = await runSubprocess("basic-injection", {
DEBUG: "1",
FORCE_COLOR: "1"
});
// Check that console methods are correctly injected
expect(output).toContain("INJECTED_LOG");
expect(output).toContain("INJECTED_INFO");
expect(output).toContain("INJECTED_WARN");
expect(output).toContain("INJECTED_ERROR");
expect(output).toContain("INJECTED_DEBUG");
// Assert should only output when condition is false
expect(output).toContain("INJECTED_ASSERT");
expect(output).not.toContain("This should not appear");
});
test("timer functions work correctly", async () => {
const output = await runSubprocess("time-functions");
// Check timer functions
expect(output).toContain("TIMER_LABEL");
expect(output).toContain("ms]"); // Time output should include milliseconds
// Warning for non-existent timer
expect(output).toContain("does not exist");
});
test("count functions work correctly", async () => {
const output = await runSubprocess("count-functions");
// Check counter functions
expect(output).toContain("COUNTER_LABEL: 1");
expect(output).toContain("COUNTER_LABEL: 2");
expect(output).toContain("COUNTER_LABEL: 1"); // After reset
});
test("environment variables affect injection", async () => {
const output = await runSubprocess("disabled-injection", { NO_COLOR: "1" });
// When NO_COLOR is set, output should still have the message
expect(output).toContain("DISABLED_COLOR_LOG");
// Note: Chalk may still output color codes even with NO_COLOR=1 in some environments
// So we're not checking for absence of color codes here
});

View file

@ -144,7 +144,12 @@ export interface LogFunction {
export function getColor(color: CustomLoggerColor): ChalkInstance { export function getColor(color: CustomLoggerColor): ChalkInstance {
if (typeof color === 'string') { if (typeof color === 'string') {
return color in chalk ? (chalk as any)[color] : chalk.hex(color); if (color in chalk) {
return (chalk as any)[color];
} else if (color.startsWith('#') || color.match(/^#[0-9a-fA-F]{6}$/)) {
return chalk.hex(color);
}
throw new Error(`Invalid color: ${color}`);
} else if (Array.isArray(color)) { } else if (Array.isArray(color)) {
return chalk.rgb(color[0], color[1], color[2]); return chalk.rgb(color[0], color[1], color[2]);
} }

56
src/test/subprocess.ts Normal file
View file

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

103
src/test/widget-mock.ts Normal file
View file

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