diff --git a/src/Progress.test.ts b/src/Progress.test.ts new file mode 100644 index 0000000..12dc00b --- /dev/null +++ b/src/Progress.test.ts @@ -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 = Record> = import("@paperclover/console/Progress").Progress; + +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 + }); + } +}); \ No newline at end of file diff --git a/src/Progress.ts b/src/Progress.ts index 00b6cd5..e37ca8b 100644 --- a/src/Progress.ts +++ b/src/Progress.ts @@ -217,7 +217,7 @@ export class Progress = Record = Record> = import("@paperclover/console/Spinner").Spinner; + +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"] + }); + } +}); diff --git a/src/Spinner.ts b/src/Spinner.ts index b0ae2c4..2e82561 100644 --- a/src/Spinner.ts +++ b/src/Spinner.ts @@ -1,4 +1,3 @@ -import chalk, { type ChalkInstance } from 'chalk'; import { Widget } from './Widget.ts'; import { getColor, type CustomLoggerColor } from './internal.ts'; @@ -23,9 +22,9 @@ export const defaultSpinnerOptions = { } as const; export class Spinner = Record> extends Widget { + readonly frames: readonly string[]; #text: string | ((props: Props) => string); #color: ((text: string) => string) | null; - #frames: readonly string[]; #props: Props; fps: number; @@ -37,7 +36,7 @@ export class Spinner = Record = Record { + 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"); +}); diff --git a/src/error.test.ts b/src/error.test.ts new file mode 100644 index 0000000..d02c392 --- /dev/null +++ b/src/error.test.ts @@ -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 "); +}); + +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 "); + } +}); \ No newline at end of file diff --git a/src/inject.test.ts b/src/inject.test.ts new file mode 100644 index 0000000..5f4e591 --- /dev/null +++ b/src/inject.test.ts @@ -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 = {}): Promise { + 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 +}); \ No newline at end of file diff --git a/src/internal.ts b/src/internal.ts index 723719a..55d1ebe 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -144,7 +144,12 @@ export interface LogFunction { export function getColor(color: CustomLoggerColor): ChalkInstance { 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)) { return chalk.rgb(color[0], color[1], color[2]); } diff --git a/src/test/subprocess.ts b/src/test/subprocess.ts new file mode 100644 index 0000000..a5c4de4 --- /dev/null +++ b/src/test/subprocess.ts @@ -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); + }); +} \ No newline at end of file diff --git a/src/test/widget-mock.ts b/src/test/widget-mock.ts new file mode 100644 index 0000000..57b189a --- /dev/null +++ b/src/test/widget-mock.ts @@ -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 ?? [], + }); +} \ No newline at end of file