diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 031b37e..c13000d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,17 +25,18 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Run tests - run: bun test - - name: Run tests with coverage - run: bun test --coverage + run: bun test --coverage --coverage-reporter=lcov - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: false + files: ./coverage/lcov.info + flags: unittests + name: codecov-umbrella + fail_ci_if_error: true + verbose: true - name: Run linting (OxLint) run: bun run lint diff --git a/README.md b/README.md index 18f706d..95f0410 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,12 @@ LogsDX is a schema-based theming engine that applies consistent visual styling t ## Features -- 🎨 **Unified Theming** - One theme works in terminal, browser, and CI/CD -- 🚀 **Zero Dependencies** - Works with any existing logger -- 📦 **8+ Built-in Themes** - Production-ready themes included -- 🛠️ **CLI Tool** - Process log files with beautiful formatting -- ♿ **Accessible** - WCAG compliance checking built-in -- 🌓 **Light/Dark Mode** - Automatic theme adaptation +- **Unified Theming** - One theme works in terminal, browser, and CI/CD +- **Zero Dependencies** - Works with any existing logger +- **8+ Built-in Themes** - Production-ready themes included +- **CLI Tool** - Process log files with beautiful formatting +- **Accessible** - WCAG compliance checking built-in +- **Light/Dark Mode** - Automatic theme adaptation ## Installation @@ -514,10 +514,10 @@ bun run format # Format code The setup script will: -- ✅ Install all dependencies -- ✅ Build the main package -- ✅ Configure git hooks (pre-commit, post-merge, post-checkout) -- ✅ Verify your environment is ready +- Install all dependencies +- Build the main package +- Configure git hooks (pre-commit, post-merge, post-checkout) +- Verify your environment is ready ## License @@ -525,6 +525,6 @@ MIT © LogsDX Contributors ## Support -- 📘 [Documentation](https://jeffry.in/logsdx) -- 🐛 [Report Issues](https://github.com/yowainwright/logsdx/issues) -- 💬 [Discussions](https://github.com/yowainwright/logsdx/discussions) +- [Documentation](https://jeffry.in/logsdx) +- [Report Issues](https://github.com/yowainwright/logsdx/issues) +- [Discussions](https://github.com/yowainwright/logsdx/discussions) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..0be4076 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,36 @@ +codecov: + require_ci_to_pass: yes + +coverage: + precision: 2 + round: down + range: "85...100" + + status: + project: + default: + target: 95% + threshold: 1% + if_ci_failed: error + + patch: + default: + target: 90% + threshold: 5% + +comment: + layout: "reach, diff, flags, files" + behavior: default + require_changes: false + require_base: false + require_head: true + +ignore: + - "tests/**/*" + - "**/*.test.ts" + - "**/*.config.ts" + - "**/*.config.js" + - "scripts/**/*" + - "site/**/*" + - "examples/**/*" + - "dist/**/*" diff --git a/package.json b/package.json index 6c9b8a4..36403c4 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "build:cli": "bun build src/cli/bin.ts --outfile dist/cli.js --format cjs --minify --target node", "build:types": "tsc -p tsconfig.build.json", "test": "bun test", + "test:coverage": "bun test --coverage --coverage-reporter=lcov", "test:watch": "bun test --watch", "lint": "oxlint .", "lint:fix": "oxlint . --fix", diff --git a/scripts/install-hooks.ts b/scripts/install-hooks.ts index 299496c..a0fa3ff 100644 --- a/scripts/install-hooks.ts +++ b/scripts/install-hooks.ts @@ -6,12 +6,12 @@ import { join } from "path"; const HOOKS_DIR = ".git/hooks"; const PRE_COMMIT = `#!/bin/sh -cd "\$(git rev-parse --show-toplevel)" || exit 1 +cd "$(git rev-parse --show-toplevel)" || exit 1 bun run format && bun run lint && bun test `; const POST_CHECKOUT = `#!/bin/sh -changed=\$(git diff-tree -r --name-only --no-commit-id $1 $2 2>/dev/null) +changed=$(git diff-tree -r --name-only --no-commit-id $1 $2 2>/dev/null) if echo "$changed" | grep -q "package.json\\|bun.lock"; then echo "Dependencies changed, running bun install..." bun install @@ -19,7 +19,7 @@ fi `; const POST_MERGE = `#!/bin/sh -changed=\$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD 2>/dev/null) +changed=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD 2>/dev/null) if echo "$changed" | grep -q "package.json\\|bun.lock"; then echo "Dependencies changed, running bun install..." bun install diff --git a/scripts/remove-comments.ts b/scripts/remove-comments.ts new file mode 100644 index 0000000..157b26e --- /dev/null +++ b/scripts/remove-comments.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env bun + +import { readdir, readFile, writeFile } from "fs/promises"; +import { join } from "path"; + +async function getAllTsFiles(dir: string): Promise { + const files: string[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await getAllTsFiles(fullPath))); + } else if (entry.name.endsWith(".ts")) { + files.push(fullPath); + } + } + + return files; +} + +function removeComments(code: string): string { + let result = ""; + let i = 0; + let inString = false; + let stringChar = ""; + let inTemplate = false; + + while (i < code.length) { + const char = code[i]; + const nextChar = code[i + 1]; + + if (!inString && !inTemplate && char === "/" && nextChar === "/") { + const lineEnd = code.indexOf("\n", i); + if (lineEnd === -1) { + break; + } + result += "\n"; + i = lineEnd + 1; + continue; + } + + if (!inString && !inTemplate && char === "/" && nextChar === "*") { + const commentEnd = code.indexOf("*/", i + 2); + if (commentEnd === -1) { + break; + } + const commentContent = code.substring(i, commentEnd + 2); + const newlines = (commentContent.match(/\n/g) || []).length; + result += "\n".repeat(newlines); + i = commentEnd + 2; + continue; + } + + if (!inTemplate && (char === '"' || char === "'")) { + if (!inString) { + inString = true; + stringChar = char; + } else if (char === stringChar && code[i - 1] !== "\\") { + inString = false; + stringChar = ""; + } + } + + if (!inString && char === "`") { + inTemplate = !inTemplate; + } + + result += char; + i++; + } + + return result; +} + +async function processFile(filePath: string) { + console.log(`Processing: ${filePath}`); + const content = await readFile(filePath, "utf-8"); + const cleaned = removeComments(content); + await writeFile(filePath, cleaned, "utf-8"); +} + +async function main() { + const srcFiles = await getAllTsFiles("src"); + const testFiles = await getAllTsFiles("tests"); + const allFiles = [...srcFiles, ...testFiles]; + + console.log(`Found ${allFiles.length} TypeScript files`); + + for (const file of allFiles) { + await processFile(file); + } + + console.log("Done!"); +} + +main().catch(console.error); diff --git a/src/cli/bin.ts b/src/cli/bin.ts index 0fbb96c..5988340 100644 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { createCLI } from "../utils/cli"; +import { createCLI } from "./parser"; import { main } from "./index"; import type { CommanderOptions } from "./types"; import { version } from "../../package.json"; diff --git a/src/cli/commands/theme.ts b/src/cli/commands.ts similarity index 93% rename from src/cli/commands/theme.ts rename to src/cli/commands.ts index 479db1f..71243bd 100644 --- a/src/cli/commands/theme.ts +++ b/src/cli/commands.ts @@ -1,9 +1,9 @@ -import { createCLI, CLI } from "../../utils/cli"; -import { input, select, checkbox, confirm } from "../../utils/prompts"; -import spinner from "../../utils/spinner"; -import * as colorUtil from "../../utils/colors"; -import gradient from "../../utils/gradient"; -import boxen from "../../utils/boxen"; +import { createCLI, CLI } from "./parser"; +import { input, select, checkbox, confirm } from "../utils/prompts"; +import spinner from "../utils/spinner"; +import * as colorUtil from "../utils/colors"; +import gradient from "../utils/gradient"; +import boxen from "../utils/boxen"; import { writeFileSync, existsSync, mkdirSync } from "fs"; import { dirname } from "path"; import { @@ -11,12 +11,11 @@ import { checkWCAGCompliance, adjustThemeForAccessibility, SimpleThemeConfig, -} from "../../themes/builder"; -import { registerTheme, getTheme, getThemeNames } from "../../themes"; -import { getLogsDX } from "../../index"; -import { Theme } from "../../types"; +} from "../themes/builder"; +import { registerTheme, getTheme, getThemeNames } from "../themes"; +import { getLogsDX } from "../index"; +import { Theme } from "../types"; -// Sample logs for preview const SAMPLE_LOGS = [ "INFO: Server started on port 3000", "WARN: Memory usage high: 85%", @@ -28,7 +27,6 @@ const SAMPLE_LOGS = [ "Cache hit ratio: 92.5%", ]; -// Color presets const COLOR_PRESETS = { Vibrant: { primary: "#007acc", @@ -105,7 +103,6 @@ async function createInteractiveTheme(options: { skipIntro?: boolean } = {}) { showBanner(); } - // Basic info const name = await input({ message: "Theme name:", validate: (inputValue: string) => { @@ -138,7 +135,6 @@ async function createInteractiveTheme(options: { skipIntro?: boolean } = {}) { mode, }; - // Color selection const preset = await select({ message: "Choose a color preset:", choices: Object.keys(COLOR_PRESETS).map((name) => ({ @@ -211,7 +207,6 @@ async function createInteractiveTheme(options: { skipIntro?: boolean } = {}) { colors = { primary, error, warning, success, info, muted }; } - // Feature presets const presets = await checkbox({ message: "Select features to highlight:", choices: [ @@ -236,7 +231,6 @@ async function createInteractiveTheme(options: { skipIntro?: boolean } = {}) { ], }); - // Create theme const createSpinner = spinner("Creating theme...").start(); const config: SimpleThemeConfig = { @@ -250,11 +244,9 @@ async function createInteractiveTheme(options: { skipIntro?: boolean } = {}) { const theme = createTheme(config); createSpinner.succeed("Theme created!"); - // Preview console.log("\n"); renderPreview(theme, `✨ ${theme.name} Preview`); - // Accessibility check const checkAccessibility = await confirm({ message: "Check accessibility compliance?", default: true, @@ -301,7 +293,6 @@ async function createInteractiveTheme(options: { skipIntro?: boolean } = {}) { } } - // Save options const saveOption = await select({ message: "How would you like to save the theme?", choices: [ diff --git a/src/cli/index.ts b/src/cli/index.ts index ee8ba74..7e6489a 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,3 @@ -import { createCLI } from "../utils/cli"; import fs from "fs"; import path from "path"; import { LogsDX, getThemeNames } from "../index"; @@ -15,8 +14,8 @@ import { runThemeGenerator, listColorPalettesCommand, listPatternPresetsCommand, -} from "./theme/generator"; -import { exportTheme, importTheme, listThemeFiles } from "./theme/transporter"; +} from "./theme-gen"; +import { exportTheme, importTheme, listThemeFiles } from "./theme-gen"; export function loadConfig(configPath?: string): LogsDXOptions { const defaultConfig: LogsDXOptions = { diff --git a/src/cli/interactive.ts b/src/cli/interactive.ts index ec98c2e..b28de4f 100644 --- a/src/cli/interactive.ts +++ b/src/cli/interactive.ts @@ -39,7 +39,6 @@ export async function runInteractiveMode(): Promise { ), ); - // Theme selection const themeNames = getThemeNames(); const themeChoices: ThemeChoice[] = themeNames.map((name: string) => ({ name: chalk.cyan(name), @@ -79,7 +78,6 @@ export async function runInteractiveMode(): Promise { }); } - // Output format selection const outputFormat = await select({ message: "📤 Choose output format:", choices: [ @@ -96,7 +94,6 @@ export async function runInteractiveMode(): Promise { ], }); - // Preview confirmation const wantPreview = await confirm({ message: "👀 Show a preview with your settings?", default: true, diff --git a/src/utils/cli/index.ts b/src/cli/parser.ts similarity index 75% rename from src/utils/cli/index.ts rename to src/cli/parser.ts index 9a2d06a..73a42a9 100644 --- a/src/utils/cli/index.ts +++ b/src/cli/parser.ts @@ -1,16 +1,50 @@ -import type { - OptionDefinition, - ParsedOptions, - ArgumentDefinition, -} from "./types"; -import { HELP_FLAGS, VERSION_FLAGS, BOOLEAN_FLAG_PREFIX } from "./constants"; -import { - camelCase, - findOption, - extractLongFlag, - expectsValue, - hasOptionalValue, -} from "./utils"; +export interface OptionDefinition { + flags: string; + description: string; + defaultValue?: string | boolean | number; +} + +export interface ParsedOptions { + [key: string]: string | boolean | number | undefined; +} + +export interface ArgumentDefinition { + name: string; + description: string; + required: boolean; +} + +export const HELP_FLAGS = ["--help", "-h"]; +export const VERSION_FLAGS = ["--version", "-v"]; +export const BOOLEAN_FLAG_PREFIX = "no-"; + +export function camelCase(str: string): string { + return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +export function findOption( + options: OptionDefinition[], + flag: string, +): OptionDefinition | undefined { + return options.find((o) => o.flags.includes(flag)); +} + +export function extractLongFlag(flags: string): string | null { + const match = flags.match(/--([a-z-]+)/); + return match ? match[1] : null; +} + +export function expectsValue(flags: string): boolean { + return flags.includes("<"); +} + +export function hasOptionalValue(flags: string): boolean { + return flags.includes("[") && !flags.startsWith("["); +} + +export function isBooleanFlag(flags: string): boolean { + return !expectsValue(flags) && !hasOptionalValue(flags); +} export class CLI { private programName = ""; @@ -38,14 +72,18 @@ export class CLI { return this; } - option(flags: string, description: string, defaultValue?: any): this { + option( + flags: string, + description: string, + defaultValue?: string | boolean | number, + ): this { this.options.push({ flags, description, defaultValue }); return this; } argument(name: string, description: string): this { const required = !name.startsWith("["); - const cleanName = name.replace(/[\[\]]/g, ""); + const cleanName = name.replace(/[[\]]/g, ""); this.argumentDef = { name: cleanName, description, required }; return this; } @@ -177,9 +215,4 @@ export function createCLI(): CLI { return new CLI(); } -export { - type OptionDefinition, - type ParsedOptions, - type ArgumentDefinition, -} from "./types"; export default CLI; diff --git a/src/cli/theme/generator.ts b/src/cli/theme-gen.ts similarity index 59% rename from src/cli/theme/generator.ts rename to src/cli/theme-gen.ts index 09b86b6..7d2b5fb 100644 --- a/src/cli/theme/generator.ts +++ b/src/cli/theme-gen.ts @@ -1,7 +1,8 @@ -import { select, input, checkbox, confirm } from "../../utils/prompts"; -import { ui } from "../ui"; +import { select, input, checkbox, confirm } from "../utils/prompts"; +import { ui } from "./ui"; import chalk from "chalk"; import fs from "fs"; +import path from "path"; import { listColorPalettes, listPatternPresets, @@ -9,9 +10,11 @@ import { type ColorPalette, type PatternPreset, type ThemeGeneratorConfig, -} from "../../themes/template"; -import { registerTheme } from "../../themes"; -import type { Theme, PatternMatch } from "../../types"; +} from "../themes/presets"; +import { registerTheme, getAllThemes, getTheme } from "../themes"; +import type { Theme, PatternMatch } from "../types"; +import { themePresetSchema } from "../schema"; +import { LogsDX } from "../index"; export async function runThemeGenerator(): Promise { ui.showHeader(); @@ -284,7 +287,6 @@ async function showThemePreview(theme: Theme, palette: ColorPalette) { "192.168.1.100 - Processing request in 15ms", ]; - const { LogsDX } = await import("../../index"); registerTheme(theme); const logsDX = LogsDX.getInstance({ theme: theme.name, @@ -391,21 +393,18 @@ export function validateColorInput(color: string): boolean | string { return false; } - // Check hex pattern - must start with # if (color.match(/^[0-9a-fA-F]+$/)) { - return false; // Hex without # is invalid + return false; } if (color.startsWith("#")) { return hexPattern.test(color); } - // Check RGB pattern if (color.startsWith("rgb")) { return rgbPattern.test(color); } - // Check named colors return namedColors.includes(color.toLowerCase()); } @@ -456,7 +455,6 @@ interface ThemeAnswers { } export function generateTemplateFromAnswers(answers: ThemeAnswers): Theme { - // Map features to pattern presets const patternPresets = answers.patterns || answers.patternPresets || []; if (answers.features && answers.features.includes("logLevels")) { patternPresets.push("log-levels"); @@ -478,12 +476,10 @@ export function generateTemplateFromAnswers(answers: ThemeAnswers): Theme { theme.mode = answers.mode as Theme["mode"]; } - // Ensure schema exists if (!theme.schema) { theme.schema = {}; } - // Add features that aren't in pattern presets if (answers.features) { if ( answers.features.includes("numbers") || @@ -495,7 +491,6 @@ export function generateTemplateFromAnswers(answers: ThemeAnswers): Theme { theme.schema.matchWords.null = { color: "#808080" }; } if (answers.features.includes("brackets")) { - // Initialize if not exists theme.schema.matchStartsWith = theme.schema.matchStartsWith || {}; theme.schema.matchEndsWith = theme.schema.matchEndsWith || {}; theme.schema.matchStartsWith["["] = { color: "#ffff00" }; @@ -551,3 +546,348 @@ export function generatePatternFromPreset( return patternMap[presetName] || {}; } + +export async function exportTheme(themeName?: string): Promise { + const availableThemes = Object.keys(getAllThemes()); + + if (availableThemes.length === 0) { + ui.showWarning("No themes available to export"); + return; + } + + const themeToExport = + themeName || + (await select({ + message: "Select theme to export:", + choices: availableThemes.map((name) => ({ + name: chalk.cyan(name), + value: name, + })), + })); + + const theme = getTheme(themeToExport); + if (!theme) { + ui.showError(`Theme "${themeToExport}" not found`); + return; + } + + const defaultFilename = `${themeToExport.replace(/[^a-zA-Z0-9-_]/g, "-")}.theme.json`; + const filename = await input({ + message: "Export filename:", + default: defaultFilename, + validate: (value) => { + if (!value.trim()) return "Filename is required"; + if (!value.endsWith(".json")) return "Filename should end with .json"; + return true; + }, + }); + + try { + const exportData = { + ...theme, + exportedAt: new Date().toISOString(), + exportedBy: "LogsDX Theme Generator", + version: "1.0.0", + }; + + fs.writeFileSync(filename, JSON.stringify(exportData, null, 2)); + ui.showSuccess( + `Theme "${themeToExport}" exported to ${chalk.cyan(filename)}`, + ); + + const showPreview = await confirm({ + message: "Show file preview?", + default: false, + }); + + if (showPreview) { + console.log(chalk.dim("\nFile contents:")); + console.log(chalk.dim("─".repeat(50))); + console.log(JSON.stringify(exportData, null, 2)); + console.log(chalk.dim("─".repeat(50))); + } + } catch (error) { + ui.showError( + `Failed to export theme: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export function exportThemeToFile( + theme: Theme, + filePath: string, + format: "json" | "typescript" = "json", +): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (format === "typescript") { + const tsContent = `import type { Theme } from "logsdx"; + +export const theme: Theme = ${JSON.stringify(theme, null, 2)}; + +export default theme; +`; + fs.writeFileSync(filePath, tsContent); + } else { + fs.writeFileSync(filePath, JSON.stringify(theme, null, 2)); + } +} + +export function importThemeFromFile(filePath: string): Theme { + const fileContent = fs.readFileSync(filePath, "utf8"); + + if (filePath.endsWith(".ts") || filePath.endsWith(".js")) { + const patterns = [ + /export\s+const\s+\w+\s*:\s*\w+\s*=\s*(\{[\s\S]*?\})\s*;?\s*$/m, + /export\s+default\s+(\{[\s\S]*?\})\s*;?\s*$/m, + /=\s*(\{[\s\S]*?\})\s*;?\s*export\s+default/m, + ]; + + for (const pattern of patterns) { + const match = fileContent.match(pattern); + if (match) { + try { + const jsonStr = match[1] + .replace(/^\s+/gm, "") + .replace(/\s+$/gm, "") + .trim(); + return JSON.parse(jsonStr); + } catch { + continue; + } + } + } + throw new Error( + `Failed to parse theme file: The TypeScript/JavaScript file does not contain a valid theme export. ` + + `Expected an export statement with a theme object containing 'name' and 'schema' properties.`, + ); + } + + const parsed = JSON.parse(fileContent); + if (!parsed.name || !parsed.schema) { + const missing = []; + if (!parsed.name) missing.push("'name'"); + if (!parsed.schema) missing.push("'schema'"); + throw new Error( + `Invalid theme JSON: Missing required fields: ${missing.join(", ")}. ` + + `Theme files must contain both a 'name' string and a 'schema' object.`, + ); + } + return parsed; +} + +export async function importTheme(filename?: string): Promise { + const themeFile = + filename || + (await input({ + message: "Theme file path:", + validate: (value) => { + if (!value.trim()) return "File path is required"; + if (!fs.existsSync(value)) return "File does not exist"; + if (!value.endsWith(".json")) return "File should be a JSON file"; + return true; + }, + })); + + try { + const fileContent = fs.readFileSync(themeFile, "utf8"); + const themeData = JSON.parse(fileContent); + + const validatedTheme = themePresetSchema.parse(themeData); + + ui.showInfo(`Importing theme: ${chalk.cyan(validatedTheme.name)}`); + if (validatedTheme.description) { + console.log(`Description: ${validatedTheme.description}`); + } + + const existingTheme = getTheme(validatedTheme.name); + if (existingTheme) { + const shouldOverwrite = await confirm({ + message: `Theme "${validatedTheme.name}" already exists. Overwrite?`, + default: false, + }); + + if (!shouldOverwrite) { + const newName = await input({ + message: "Enter a new name for the theme:", + default: `${validatedTheme.name}-imported`, + validate: (value) => (value.trim() ? true : "Name is required"), + }); + validatedTheme.name = newName; + } + } + + const showPreview = await confirm({ + message: "Preview theme before importing?", + default: true, + }); + + if (showPreview) { + await previewImportedTheme(validatedTheme); + } + + const shouldImport = await confirm({ + message: `Import theme "${validatedTheme.name}"?`, + default: true, + }); + + if (shouldImport) { + registerTheme(validatedTheme); + ui.showSuccess(`Theme "${validatedTheme.name}" imported successfully!`); + console.log( + chalk.green( + `\n✨ Use your imported theme with: logsdx --theme ${validatedTheme.name} your-log-file.log`, + ), + ); + } else { + ui.showInfo("Import cancelled"); + } + } catch (error) { + if (error instanceof Error) { + if (error.message.includes("JSON")) { + ui.showError( + "Invalid JSON file", + "Make sure the file contains valid JSON", + ); + } else if (error.message.includes("validation")) { + ui.showError( + "Invalid theme format", + "The file doesn't contain a valid LogsDX theme", + ); + } else { + ui.showError(`Import failed: ${error.message}`); + } + } else { + ui.showError("Import failed", String(error)); + } + } +} + +async function previewImportedTheme(theme: Theme) { + console.log(chalk.bold("\n🎬 Theme Preview:\n")); + + const sampleLogs = [ + "2024-01-15 10:30:45 INFO Starting application server", + "2024-01-15 10:30:46 DEBUG Loading configuration files", + "2024-01-15 10:30:47 WARN Memory usage is high: 85%", + "2024-01-15 10:30:48 ERROR Database connection failed", + "GET /api/users 200 OK 45ms", + "POST /api/login 401 Unauthorized 23ms", + ]; + + registerTheme(theme); + const logsDX = LogsDX.getInstance({ + theme: theme.name, + outputFormat: "ansi", + }); + + sampleLogs.forEach((log) => { + console.log(` ${logsDX.processLine(log)}`); + }); + + console.log(chalk.bold("\n📋 Theme Details:")); + console.log(` Name: ${chalk.cyan(theme.name)}`); + if (theme.description) { + console.log(` Description: ${theme.description}`); + } + if ("exportedAt" in theme && (theme as any).exportedAt) { + console.log( + ` Exported: ${chalk.dim(new Date((theme as any).exportedAt).toLocaleString())}`, + ); + } + + const wordCount = Object.keys(theme.schema.matchWords || {}).length; + const patternCount = (theme.schema.matchPatterns || []).length; + console.log( + ` Patterns: ${chalk.yellow(patternCount)}, Words: ${chalk.yellow(wordCount)}`, + ); +} + +export function getThemeFiles(directory = "."): string[] { + try { + if (!fs.existsSync(directory)) { + return []; + } + + const files: string[] = []; + + function scanDir(dir: string) { + const items = fs.readdirSync(dir, { withFileTypes: true }); + for (const item of items) { + const fullPath = path.join(dir, item.name); + if (item.isDirectory()) { + scanDir(fullPath); + } else if ( + item.name.endsWith(".theme.json") || + item.name.endsWith(".theme.ts") || + (item.name.includes("theme") && + (item.name.endsWith(".json") || item.name.endsWith(".ts"))) + ) { + files.push(fullPath); + } + } + } + + scanDir(directory); + return files; + } catch { + return []; + } +} + +export { getThemeFiles as listThemeFiles }; + +export function listThemeFilesCommand(directory = "."): void { + try { + const files = fs + .readdirSync(directory) + .filter((file) => file.endsWith(".theme.json")) + .map((file) => path.join(directory, file)); + + if (files.length === 0) { + ui.showInfo("No theme files found in current directory"); + console.log( + chalk.dim("Theme files should have the extension .theme.json"), + ); + return; + } + + ui.showInfo(`Found ${files.length} theme file(s):\n`); + + files.forEach((file, index) => { + try { + const content = fs.readFileSync(file, "utf8"); + const themeData = JSON.parse(content); + + console.log(chalk.bold.cyan(`${index + 1}. ${path.basename(file)}`)); + console.log(` Theme: ${themeData.name || "Unknown"}`); + if (themeData.description) { + console.log(` Description: ${themeData.description}`); + } + if (themeData.exportedAt) { + console.log( + ` Exported: ${chalk.dim(new Date(themeData.exportedAt).toLocaleString())}`, + ); + } + console.log(` File: ${chalk.dim(file)}`); + console.log(); + } catch { + console.log(chalk.bold.red(`${index + 1}. ${path.basename(file)}`)); + console.log(chalk.red(` Error: Invalid theme file`)); + console.log(` File: ${chalk.dim(file)}`); + console.log(); + } + }); + + console.log( + chalk.yellow("💡 Use --import-theme to import a theme"), + ); + } catch (error) { + ui.showError( + `Failed to list theme files: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/cli/theme/transporter.ts b/src/cli/theme/transporter.ts deleted file mode 100644 index d3faa9c..0000000 --- a/src/cli/theme/transporter.ts +++ /dev/null @@ -1,361 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { ui } from "../ui"; -import { select, input, confirm } from "../../utils/prompts"; -import { registerTheme, getAllThemes, getTheme } from "../../themes"; -import type { Theme } from "../../types"; -import { themePresetSchema } from "../../schema"; -import chalk from "chalk"; - -export async function exportTheme(themeName?: string): Promise { - const availableThemes = Object.keys(getAllThemes()); - - if (availableThemes.length === 0) { - ui.showWarning("No themes available to export"); - return; - } - - const themeToExport = - themeName || - (await select({ - message: "Select theme to export:", - choices: availableThemes.map((name) => ({ - name: chalk.cyan(name), - value: name, - })), - })); - - const theme = getTheme(themeToExport); - if (!theme) { - ui.showError(`Theme "${themeToExport}" not found`); - return; - } - - const defaultFilename = `${themeToExport.replace(/[^a-zA-Z0-9-_]/g, "-")}.theme.json`; - const filename = await input({ - message: "Export filename:", - default: defaultFilename, - validate: (value) => { - if (!value.trim()) return "Filename is required"; - if (!value.endsWith(".json")) return "Filename should end with .json"; - return true; - }, - }); - - try { - const exportData = { - ...theme, - exportedAt: new Date().toISOString(), - exportedBy: "LogsDX Theme Generator", - version: "1.0.0", - }; - - fs.writeFileSync(filename, JSON.stringify(exportData, null, 2)); - ui.showSuccess( - `Theme "${themeToExport}" exported to ${chalk.cyan(filename)}`, - ); - - const showPreview = await confirm({ - message: "Show file preview?", - default: false, - }); - - if (showPreview) { - console.log(chalk.dim("\nFile contents:")); - console.log(chalk.dim("─".repeat(50))); - console.log(JSON.stringify(exportData, null, 2)); - console.log(chalk.dim("─".repeat(50))); - } - } catch (error) { - ui.showError( - `Failed to export theme: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -export function exportThemeToFile( - theme: Theme, - filePath: string, - format: "json" | "typescript" = "json", -): void { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - if (format === "typescript") { - const tsContent = `import type { Theme } from "logsdx"; - -export const theme: Theme = ${JSON.stringify(theme, null, 2)}; - -export default theme; -`; - fs.writeFileSync(filePath, tsContent); - } else { - fs.writeFileSync(filePath, JSON.stringify(theme, null, 2)); - } -} - -export function importThemeFromFile(filePath: string): Theme { - const fileContent = fs.readFileSync(filePath, "utf8"); - - if (filePath.endsWith(".ts") || filePath.endsWith(".js")) { - // Try to extract JSON object from TypeScript/JavaScript file - // Look for patterns like: export const theme = {...} or export default {...} - const patterns = [ - /export\s+const\s+\w+\s*:\s*\w+\s*=\s*(\{[\s\S]*?\})\s*;?\s*$/m, - /export\s+default\s+(\{[\s\S]*?\})\s*;?\s*$/m, - /=\s*(\{[\s\S]*?\})\s*;?\s*export\s+default/m, - ]; - - for (const pattern of patterns) { - const match = fileContent.match(pattern); - if (match) { - try { - // Clean up the JSON string - const jsonStr = match[1] - .replace(/^\s+/gm, "") // Remove leading whitespace - .replace(/\s+$/gm, "") // Remove trailing whitespace - .trim(); - return JSON.parse(jsonStr); - } catch { - continue; - } - } - } - throw new Error( - `Failed to parse theme file: The TypeScript/JavaScript file does not contain a valid theme export. ` + - `Expected an export statement with a theme object containing 'name' and 'schema' properties.`, - ); - } - - const parsed = JSON.parse(fileContent); - if (!parsed.name || !parsed.schema) { - const missing = []; - if (!parsed.name) missing.push("'name'"); - if (!parsed.schema) missing.push("'schema'"); - throw new Error( - `Invalid theme JSON: Missing required fields: ${missing.join(", ")}. ` + - `Theme files must contain both a 'name' string and a 'schema' object.`, - ); - } - return parsed; -} - -export async function importTheme(filename?: string): Promise { - const themeFile = - filename || - (await input({ - message: "Theme file path:", - validate: (value) => { - if (!value.trim()) return "File path is required"; - if (!fs.existsSync(value)) return "File does not exist"; - if (!value.endsWith(".json")) return "File should be a JSON file"; - return true; - }, - })); - - try { - const fileContent = fs.readFileSync(themeFile, "utf8"); - const themeData = JSON.parse(fileContent); - - // Validate the theme structure - const validatedTheme = themePresetSchema.parse(themeData); - - ui.showInfo(`Importing theme: ${chalk.cyan(validatedTheme.name)}`); - if (validatedTheme.description) { - console.log(`Description: ${validatedTheme.description}`); - } - - // Check if theme already exists - const existingTheme = getTheme(validatedTheme.name); - if (existingTheme) { - const shouldOverwrite = await confirm({ - message: `Theme "${validatedTheme.name}" already exists. Overwrite?`, - default: false, - }); - - if (!shouldOverwrite) { - const newName = await input({ - message: "Enter a new name for the theme:", - default: `${validatedTheme.name}-imported`, - validate: (value) => (value.trim() ? true : "Name is required"), - }); - validatedTheme.name = newName; - } - } - - // Preview the theme - const showPreview = await confirm({ - message: "Preview theme before importing?", - default: true, - }); - - if (showPreview) { - await previewImportedTheme(validatedTheme); - } - - const shouldImport = await confirm({ - message: `Import theme "${validatedTheme.name}"?`, - default: true, - }); - - if (shouldImport) { - registerTheme(validatedTheme); - ui.showSuccess(`Theme "${validatedTheme.name}" imported successfully!`); - console.log( - chalk.green( - `\n✨ Use your imported theme with: logsdx --theme ${validatedTheme.name} your-log-file.log`, - ), - ); - } else { - ui.showInfo("Import cancelled"); - } - } catch (error) { - if (error instanceof Error) { - if (error.message.includes("JSON")) { - ui.showError( - "Invalid JSON file", - "Make sure the file contains valid JSON", - ); - } else if (error.message.includes("validation")) { - ui.showError( - "Invalid theme format", - "The file doesn't contain a valid LogsDX theme", - ); - } else { - ui.showError(`Import failed: ${error.message}`); - } - } else { - ui.showError("Import failed", String(error)); - } - } -} - -async function previewImportedTheme(theme: Theme) { - console.log(chalk.bold("\n🎬 Theme Preview:\n")); - - const sampleLogs = [ - "2024-01-15 10:30:45 INFO Starting application server", - "2024-01-15 10:30:46 DEBUG Loading configuration files", - "2024-01-15 10:30:47 WARN Memory usage is high: 85%", - "2024-01-15 10:30:48 ERROR Database connection failed", - "GET /api/users 200 OK 45ms", - "POST /api/login 401 Unauthorized 23ms", - ]; - - // Temporarily register the theme for preview - const { LogsDX } = await import("../../index"); - registerTheme(theme); - const logsDX = LogsDX.getInstance({ - theme: theme.name, - outputFormat: "ansi", - }); - - sampleLogs.forEach((log) => { - console.log(` ${logsDX.processLine(log)}`); - }); - - console.log(chalk.bold("\n📋 Theme Details:")); - console.log(` Name: ${chalk.cyan(theme.name)}`); - if (theme.description) { - console.log(` Description: ${theme.description}`); - } - if ("exportedAt" in theme && (theme as any).exportedAt) { - console.log( - ` Exported: ${chalk.dim(new Date((theme as any).exportedAt).toLocaleString())}`, - ); - } - - const wordCount = Object.keys(theme.schema.matchWords || {}).length; - const patternCount = (theme.schema.matchPatterns || []).length; - console.log( - ` Patterns: ${chalk.yellow(patternCount)}, Words: ${chalk.yellow(wordCount)}`, - ); -} - -export function getThemeFiles(directory = "."): string[] { - try { - if (!fs.existsSync(directory)) { - return []; - } - - const files: string[] = []; - - function scanDir(dir: string) { - const items = fs.readdirSync(dir, { withFileTypes: true }); - for (const item of items) { - const fullPath = path.join(dir, item.name); - if (item.isDirectory()) { - scanDir(fullPath); - } else if ( - item.name.endsWith(".theme.json") || - item.name.endsWith(".theme.ts") || - (item.name.includes("theme") && - (item.name.endsWith(".json") || item.name.endsWith(".ts"))) - ) { - files.push(fullPath); - } - } - } - - scanDir(directory); - return files; - } catch { - return []; - } -} - -export { getThemeFiles as listThemeFiles }; - -export function listThemeFilesCommand(directory = "."): void { - try { - const files = fs - .readdirSync(directory) - .filter((file) => file.endsWith(".theme.json")) - .map((file) => path.join(directory, file)); - - if (files.length === 0) { - ui.showInfo("No theme files found in current directory"); - console.log( - chalk.dim("Theme files should have the extension .theme.json"), - ); - return; - } - - ui.showInfo(`Found ${files.length} theme file(s):\n`); - - files.forEach((file, index) => { - try { - const content = fs.readFileSync(file, "utf8"); - const themeData = JSON.parse(content); - - console.log(chalk.bold.cyan(`${index + 1}. ${path.basename(file)}`)); - console.log(` Theme: ${themeData.name || "Unknown"}`); - if (themeData.description) { - console.log(` Description: ${themeData.description}`); - } - if (themeData.exportedAt) { - console.log( - ` Exported: ${chalk.dim(new Date(themeData.exportedAt).toLocaleString())}`, - ); - } - console.log(` File: ${chalk.dim(file)}`); - console.log(); - } catch { - console.log(chalk.bold.red(`${index + 1}. ${path.basename(file)}`)); - console.log(chalk.red(` Error: Invalid theme file`)); - console.log(` File: ${chalk.dim(file)}`); - console.log(); - } - }); - - console.log( - chalk.yellow("💡 Use --import-theme to import a theme"), - ); - } catch (error) { - ui.showError( - `Failed to list theme files: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} diff --git a/src/cli/types.ts b/src/cli/types.ts index f9f6170..b852979 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -13,7 +13,6 @@ export const cliOptionsSchema = z.object({ noSpinner: z.boolean().optional().default(false), format: z.enum(["ansi", "html"]).optional(), - // Theme generation options generateTheme: z.boolean().optional().default(false), listPalettes: z.boolean().optional().default(false), listPatterns: z.boolean().optional().default(false), @@ -25,7 +24,6 @@ export const cliOptionsSchema = z.object({ export type CliOptions = z.infer; export type CommanderOptions = CliOptions; -// UI Interfaces export interface SpinnerLike { start(): this; succeed(message?: string): this; diff --git a/src/cli/utils.ts b/src/cli/utils.ts index 0d8fba7..a2925b6 100644 --- a/src/cli/utils.ts +++ b/src/cli/utils.ts @@ -1,10 +1,5 @@ import { SIZE_UNITS, SIZE_UNIT_MULTIPLIER } from "./constants"; -/** - * Format file size in human-readable format - * @param bytes - Size in bytes - * @returns Formatted size string - */ export function formatFileSize(bytes: number): string { if (bytes === 0) { return "0 B"; @@ -20,20 +15,10 @@ export function formatFileSize(bytes: number): string { return `${size.toFixed(1)} ${unit}`; } -/** - * Format number with thousands separators - * @param num - Number to format - * @returns Formatted number string - */ export function formatNumber(num: number): string { return num.toLocaleString(); } -/** - * Check if file exists - * @param path - File path - * @returns True if file exists - */ export function fileExists(path: string): boolean { try { return require("fs").existsSync(path); @@ -42,10 +27,6 @@ export function fileExists(path: string): boolean { } } -/** - * Ensure directory exists - * @param dirPath - Directory path - */ export function ensureDir(dirPath: string): void { const fs = require("fs"); if (!fs.existsSync(dirPath)) { diff --git a/src/index.ts b/src/index.ts index b69bc6c..c8c592a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,10 +50,6 @@ export class LogsDX { }, }; - /** - * Create a new LogsDX instance - * @param options Configuration options - */ private constructor(options = {}) { this.options = { theme: "none", @@ -69,11 +65,6 @@ export class LogsDX { this.currentTheme = this.resolveTheme(this.options.theme); } - /** - * Resolve theme from various input formats - * @param theme Theme input in various formats - * @returns Resolved Theme object - */ private resolveTheme(theme: string | Theme | ThemePair | undefined): Theme { if (!theme || theme === "none") { return { @@ -147,14 +138,13 @@ export class LogsDX { } } } else { - // Direct Theme object try { return validateTheme(theme as Theme); } catch (error) { if (this.options.debug) { console.warn("Invalid custom theme:", error); } - // Return no-op theme on validation failure + return { name: "none", description: "No styling applied", @@ -172,22 +162,15 @@ export class LogsDX { } } - /** - * Get a singleton instance of LogsDX - * @param options Configuration options - * @returns LogsDX instance - */ static getInstance(options: LogsDXOptions = {}): LogsDX { if (!LogsDX.instance) { LogsDX.instance = new LogsDX(options); } else if (Object.keys(options).length > 0) { - // Update existing instance with new options LogsDX.instance.options = { ...LogsDX.instance.options, ...options, }; - // If theme changed, update the current theme if (options.theme) { LogsDX.instance.currentTheme = LogsDX.instance.resolveTheme( options.theme, @@ -201,11 +184,6 @@ export class LogsDX { LogsDX.instance = null; } - /** - * Process a log line with the current theme - * @param line Log line to process - * @returns The styled log line - */ processLine(line: string): string { const renderOptions: RenderOptions = { theme: this.currentTheme, @@ -214,13 +192,10 @@ export class LogsDX { escapeHtml: this.options.escapeHtml, }; - // First tokenize the line const tokens = tokenize(line, this.currentTheme); - // Then apply the theme to get styled tokens const styledTokens = applyTheme(tokens, this.currentTheme); - // Now render the styled tokens based on output format if (renderOptions.outputFormat === "html") { if (renderOptions.htmlStyleFormat === "className") { return tokensToClassNames(styledTokens); @@ -228,45 +203,24 @@ export class LogsDX { return tokensToHtml(styledTokens); } } else { - // Default to ANSI output return tokensToString(styledTokens); } } - /** - * Process multiple log lines - * @param lines Array of log lines - * @returns Array of styled log lines - */ processLines(lines: string[]): string[] { return lines.map((line) => this.processLine(line)); } - /** - * Process a log string with multiple lines - * @param logContent Multi-line log content - * @returns The styled log content - */ processLog(logContent: string): string { const lines = logContent.split("\n"); const processedLines = this.processLines(lines); return processedLines.join("\n"); } - /** - * Tokenize a log line without applying styling - * @param line Log line to tokenize - * @returns Tokenized log line - */ tokenizeLine(line: string): TokenList { return tokenize(line, this.currentTheme); } - /** - * Set the current theme - * @param theme Theme name, custom theme configuration, or theme pair - * @returns True if theme was valid and applied, false otherwise - */ setTheme(theme: string | Theme | ThemePair): boolean { try { this.options.theme = theme; @@ -280,77 +234,35 @@ export class LogsDX { } } - /** - * Get the current theme - * @returns Current theme configuration - */ getCurrentTheme(): Theme { return this.currentTheme; } - /** - * Get all available themes - * @returns Object containing all available themes - */ getAllThemes(): Record { return getAllThemes(); } - /** - * Get names of all available themes - * @returns Array of theme names - */ getThemeNames(): string[] { return getThemeNames(); } - /** - * Set the output format - * @param format The output format to use ('ansi' or 'html') - */ setOutputFormat(format: "ansi" | "html"): void { this.options.outputFormat = format; } - /** - * Set the HTML style format - * @param format The HTML style format to use ('css' or 'className') - */ setHtmlStyleFormat(format: "css" | "className"): void { this.options.htmlStyleFormat = format; } - /** - * Get the current output format - * @returns The current output format - */ getCurrentOutputFormat(): "ansi" | "html" { return this.options.outputFormat; } - /** - * Get the current HTML style format - * @returns The current HTML style format - */ getCurrentHtmlStyleFormat(): "css" | "className" { return this.options.htmlStyleFormat; } } -/** - * Get or create a LogsDX instance - * @param options - Configuration options for LogsDX - * @param options.theme - Theme name, Theme object, or ThemePair for light/dark modes - * @param options.outputFormat - Output format ('ansi' for terminal, 'html' for web) - * @param options.htmlStyleFormat - HTML style format ('css' for inline styles, 'className' for CSS classes) - * @param options.debug - Enable debug mode for additional logging - * @param options.customRules - Custom parsing rules - * @param options.autoAdjustTerminal - Automatically adjust theme based on terminal background - * @returns LogsDX instance - * @example - * const logsdx = getLogsDX({ theme: 'github-dark' }); - * const styledLine = logsdx.processLine('ERROR: Failed to connect'); - */ export function getLogsDX(options?: LogsDXOptions): LogsDX { return LogsDX.getInstance(options); } diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index 0d64e79..f20aa52 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -41,10 +41,6 @@ export const CLASS_ITALIC = "logsdx-italic"; export const CLASS_UNDERLINE = "logsdx-underline"; export const CLASS_DIM = "logsdx-dim"; -/** - * Check if the terminal supports colors - * @returns True if colors are supported - */ export function supportsColors(): boolean { if (process.env.NO_COLOR) { return false; @@ -181,12 +177,6 @@ export const TEXT_COLORS: Record = { }, }; -/** - * Get color definition with theme-aware fallback - * @param colorName - The color name from the theme - * @param theme - Optional theme for theme-specific colors - * @returns ColorDefinition or undefined if not found - */ export function getColorDefinition( colorName: string, theme?: Theme, @@ -238,11 +228,6 @@ export function getColorDefinition( return undefined; } -/** - * Convert hex color to RGB values - * @param hex - Hex color string (e.g., "#ff0000") - * @returns RGB array [r, g, b] - */ function hexToRgb(hex: string): [number, number, number] { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result diff --git a/src/renderer/detectBackground.ts b/src/renderer/detectBackground.ts index 19ddfc3..8f7897a 100644 --- a/src/renderer/detectBackground.ts +++ b/src/renderer/detectBackground.ts @@ -88,10 +88,6 @@ function detectFromVSCode(): BackgroundInfo | undefined { }); } -/** - * Detect terminal background using various methods - * @returns Background information for terminal - */ export function detectTerminalBackground(): BackgroundInfo { const fromColorFgBg = detectFromColorFgBg(); if (fromColorFgBg) { @@ -120,10 +116,6 @@ function matchesColorScheme(scheme: "dark" | "light"): boolean { return query.matches; } -/** - * Detect browser background using media queries - * @returns Background information for browser - */ export function detectBrowserBackground(): BackgroundInfo { if (!hasMatchMedia()) { return DEFAULT_AUTO_BACKGROUND; @@ -188,10 +180,6 @@ function detectFromLinux(): BackgroundInfo | undefined { }); } -/** - * Detect system background based on platform - * @returns Background information for system - */ export function detectSystemBackground(): BackgroundInfo { const fromMacOS = detectFromMacOS(); if (fromMacOS) { @@ -221,10 +209,6 @@ function hasHigherConfidence(a: ConfidenceLevel, b: ConfidenceLevel): boolean { return confidenceOrder[a] >= confidenceOrder[b]; } -/** - * Detect background from all available sources - * @returns Background information - */ export function detectBackground(): BackgroundInfo { if (isBrowser()) { const browserInfo = detectBrowserBackground(); @@ -251,10 +235,6 @@ export function detectBackground(): BackgroundInfo { : systemInfo; } -/** - * Check if background is dark - * @returns True if background is dark - */ export function isDarkBackground(): boolean { const info = detectBackground(); const isDefaultAuto = info.scheme === "auto" && info.source === "default"; @@ -262,19 +242,11 @@ export function isDarkBackground(): boolean { return info.scheme === "dark" || isDefaultAuto; } -/** - * Check if background is light - * @returns True if background is light - */ export function isLightBackground(): boolean { const info = detectBackground(); return info.scheme === "light"; } -/** - * Get recommended theme mode based on background - * @returns Recommended theme mode - */ export function getRecommendedThemeMode(): "light" | "dark" { return isDarkBackground() ? "dark" : "light"; } @@ -316,11 +288,6 @@ function setupMediaQueryListeners( return () => {}; } -/** - * Watch for background changes - * @param callback - Callback function to be called when background changes - * @returns Cleanup function to stop watching - */ export function watchBackgroundChanges( callback: (info: BackgroundInfo) => void, ): () => void { diff --git a/src/renderer/index.ts b/src/renderer/index.ts index a6b0428..61ae8a3 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -109,12 +109,6 @@ export function tokenToString(token: Token, colorSupport: boolean): string { return result; } -/** - * Convert tokens to a styled string - * @param tokens - The tokens to convert - * @param forceColors - Force color output regardless of terminal detection - * @returns A string with ANSI escape codes for styling - */ export function tokensToString( tokens: TokenList, forceColors?: boolean, @@ -229,12 +223,6 @@ export function tokenToHtml(token: Token, options: RenderOptions): string { return wrapInSpan(content, css); } -/** - * Convert tokens to HTML with inline CSS styles - * @param tokens - The tokens to convert - * @param options - Render options - * @returns HTML string with inline CSS styles - */ export function tokensToHtml( tokens: TokenList, options: RenderOptions = {}, @@ -306,12 +294,6 @@ export function tokenToClassName(token: Token, options: RenderOptions): string { return wrapInSpanWithClass(content, classes); } -/** - * Convert tokens to HTML with CSS class names - * @param tokens - The tokens to convert - * @param options - Render options - * @returns HTML with CSS class names for styling - */ export function tokensToClassNames( tokens: TokenList, options: RenderOptions = {}, @@ -321,13 +303,6 @@ export function tokensToClassNames( .join(EMPTY_STRING); } -/** - * Render a line with the specified options - * @param line - The line to render - * @param theme - The theme to use - * @param options - Rendering options - * @returns The rendered line - */ export function renderLine( line: string, theme?: Theme, @@ -346,21 +321,10 @@ export function renderLine( return tokensToString(styledTokens); } -/** - * Highlight a line (for line highlighting) - * @param line - The line to highlight - * @returns The highlighted line - */ export function highlightLine(line: string): string { return `${LINE_HIGHLIGHT_BG}${line}${RESET}`; } -/** - * Apply color to text using ANSI escape codes - * @param text - The text to color - * @param color - The color to apply - * @returns The colored text - */ export function applyColor(text: string, color: string): string { const colorDef = getColorDefinition(color); if (!colorDef) { @@ -369,48 +333,22 @@ export function applyColor(text: string, color: string): string { return `${colorDef.ansi}${text}${STYLE_CODES.resetColor}`; } -/** - * Apply bold style to text using ANSI escape codes - * @param text - The text to style - * @returns The styled text - */ export function applyBold(text: string): string { return `${STYLE_CODES.bold}${text}${STYLE_CODES.resetBold}`; } -/** - * Apply italic style to text using ANSI escape codes - * @param text - The text to style - * @returns The styled text - */ export function applyItalic(text: string): string { return `${STYLE_CODES.italic}${text}${STYLE_CODES.resetItalic}`; } -/** - * Apply underline style to text using ANSI escape codes - * @param text - The text to style - * @returns The styled text - */ export function applyUnderline(text: string): string { return `${STYLE_CODES.underline}${text}${STYLE_CODES.resetUnderline}`; } -/** - * Apply dim style to text using ANSI escape codes - * @param text - The text to style - * @returns The styled text - */ export function applyDim(text: string): string { return `${STYLE_CODES.dim}${text}${STYLE_CODES.resetDim}`; } -/** - * Apply background color to text using ANSI escape codes - * @param text - The text to color - * @param color - The color to apply - * @returns The colored text - */ export function applyBackgroundColor(text: string, color: string): string { const colorDef = BACKGROUND_COLORS[color]; if (!colorDef) { @@ -419,33 +357,14 @@ export function applyBackgroundColor(text: string, color: string): string { return `${colorDef.ansi}${text}${STYLE_CODES.resetBackground}`; } -/** - * Creates a 256-color foreground color code - * @param code Color code (0-255) - * @returns ANSI color code string - */ export function fg256(code: number): string { return `\x1b[38;5;${code}m`; } -/** - * Creates a 24-bit RGB foreground color code - * @param r Red (0-255) - * @param g Green (0-255) - * @param b Blue (0-255) - * @returns ANSI color code string - */ export function fgRGB(r: number, g: number, b: number): string { return `\x1b[38;2;${r};${g};${b}m`; } -/** - * Render multiple lines with the specified options - * @param lines - The lines to render - * @param theme - The theme to use - * @param options - Rendering options - * @returns The rendered lines - */ export function renderLines( lines: ReadonlyArray, theme?: Theme, diff --git a/src/renderer/lightBox.ts b/src/renderer/lightBox.ts index 49e7fe3..0cb4429 100644 --- a/src/renderer/lightBox.ts +++ b/src/renderer/lightBox.ts @@ -52,11 +52,6 @@ const DEFAULT_PADDING = 2; const DEFAULT_BORDER = true; const DEFAULT_BORDER_STYLE: BorderStyle = "rounded"; -/** - * Get the background color for a theme - * @param theme - Theme object or theme name string - * @returns ANSI background color code - */ export function getThemeBackground(theme: Theme | string): string { const themeName = typeof theme === "string" ? theme : theme.name; return THEME_BACKGROUNDS[themeName] || DEFAULT_BACKGROUND; @@ -132,13 +127,6 @@ function createPaddedLine( return `${backgroundColor}${content}${RESET}`; } -/** - * Render a line in a light box - * @param line - The line to render - * @param theme - Theme object or theme name string - * @param options - Light box rendering options - * @returns Rendered line string - */ export function renderLightBoxLine( line: string, theme: Theme | string, @@ -163,14 +151,6 @@ export function renderLightBoxLine( ); } -/** - * Render a complete light box with multiple lines - * @param lines - Array of lines to render - * @param theme - Theme object or theme name string - * @param title - Optional title for the box - * @param options - Light box rendering options - * @returns Array of rendered line strings - */ export function renderLightBox( lines: ReadonlyArray, theme: Theme | string, @@ -199,11 +179,6 @@ export function renderLightBox( return result; } -/** - * Check if a theme is a light theme - * @param theme - Theme object or theme name string - * @returns True if the theme is a light theme - */ export function isLightTheme(theme: Theme | string): boolean { if (typeof theme === "object" && theme.mode) { return theme.mode === "light"; @@ -215,10 +190,6 @@ export function isLightTheme(theme: Theme | string): boolean { return lowerName.includes("light") || lowerName.includes("white"); } -/** - * Check if terminal has dark background - * @returns True if terminal background is dark - */ export function isTerminalDark(): boolean { return isDarkBackground(); } diff --git a/src/schema/validator.ts b/src/schema/validator.ts index 739add2..d2ff437 100644 --- a/src/schema/validator.ts +++ b/src/schema/validator.ts @@ -91,11 +91,6 @@ export function createThemeValidationError(error: unknown): Error { return createValidationError(message, error); } -/** - * Validates a theme configuration with enhanced error messages - * @param theme The theme configuration to validate - * @returns The validated theme configuration or throws an error - */ export function validateTheme(theme: unknown): Theme { try { return parseTheme(theme); @@ -104,11 +99,6 @@ export function validateTheme(theme: unknown): Theme { } } -/** - * Safely validates a theme configuration - * @param theme The theme configuration to validate - * @returns An object with success flag and either the validated data or error - */ export function validateThemeSafe(theme: unknown): { success: boolean; data?: Theme; diff --git a/src/themes/builder.ts b/src/themes/builder.ts index 1fc19af..83ba414 100644 --- a/src/themes/builder.ts +++ b/src/themes/builder.ts @@ -229,12 +229,12 @@ export const THEME_PRESETS: Record = { patterns: [ { name: "unix-path", - pattern: /\/[\w\-\.\/]+/g, + pattern: /\/[\w\-./]+/g, options: { color: "muted" }, }, { name: "windows-path", - pattern: /[A-Z]:\\[\w\-\.\\]+/g, + pattern: /[A-Z]:\\[\w\-.\\]+/g, options: { color: "muted" }, }, ], diff --git a/src/themes/color-constants.ts b/src/themes/colors.ts similarity index 92% rename from src/themes/color-constants.ts rename to src/themes/colors.ts index 2329543..12f7d82 100644 --- a/src/themes/color-constants.ts +++ b/src/themes/colors.ts @@ -1,7 +1,3 @@ -/** - * Color palettes for WCAG theme utilities - */ - export const colors = { gray: { 50: "#f9fafb", 100: "#f3f4f6", 800: "#1f2937", 900: "#111827" }, sky: { 300: "#7dd3fc", 400: "#38bdf8", 600: "#0284c7", 700: "#0369a1" }, diff --git a/src/themes/index.ts b/src/themes/index.ts index ae0d04a..b01d2a8 100644 --- a/src/themes/index.ts +++ b/src/themes/index.ts @@ -11,35 +11,18 @@ import { } from "./builder"; import type { ColorPalette, SimpleThemeConfig } from "./builder"; -/** - * Get a theme by name - * @param themeName Name of the theme to get - * @returns The requested theme or default theme if not found - */ export function getTheme(themeName: string): Theme { return THEMES[themeName] || (THEMES[DEFAULT_THEME] as Theme); } -/** - * Get all available themes - * @returns Object containing all available themes - */ export function getAllThemes(): Record { return THEMES; } -/** - * Get names of all available themes - * @returns Array of theme names - */ export function getThemeNames(): string[] { return Object.keys(THEMES); } -/** - * Register a custom theme to make it available for use - * @param theme The theme to register - */ export function registerTheme(theme: Theme): void { THEMES[theme.name] = theme; } diff --git a/src/themes/template.ts b/src/themes/presets.ts similarity index 99% rename from src/themes/template.ts rename to src/themes/presets.ts index e062295..f2a38d6 100644 --- a/src/themes/template.ts +++ b/src/themes/presets.ts @@ -129,7 +129,6 @@ export const themeGeneratorConfigSchema = z.object({ export type ThemeGeneratorConfig = z.infer; -// Built-in color palettes with accessibility considerations export const COLOR_PALETTES: ColorPalette[] = [ { name: "github-light", @@ -254,7 +253,6 @@ export const COLOR_PALETTES: ColorPalette[] = [ }, ]; -// Common pattern presets for different log types export const PATTERN_PRESETS: PatternPreset[] = [ { name: "http-api", diff --git a/src/themes/utils.ts b/src/themes/utils.ts index e7b35c0..f458602 100644 --- a/src/themes/utils.ts +++ b/src/themes/utils.ts @@ -1,31 +1,17 @@ -/** - * Theme utility functions for color accessibility and contrast - */ +import { colors } from "./colors"; -import { colors } from "./color-constants"; - -/** - * Determine if a hex color is dark or light based on luminance - */ export function isDarkColor(hex: string): boolean { - // Remove # if present const color = hex.replace("#", ""); - // Convert to RGB const r = parseInt(color.slice(0, 2), 16); const g = parseInt(color.slice(2, 4), 16); const b = parseInt(color.slice(4, 6), 16); - // Calculate relative luminance const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return luminance < 0.5; } -/** - * Get accessible text colors for a given background - * Returns Tailwind colors that meet WCAG contrast requirements - */ export function getAccessibleTextColors( backgroundColor: string, contrastLevel: "AAA" | "AA" = "AA", @@ -33,7 +19,6 @@ export function getAccessibleTextColors( const isDark = isDarkColor(backgroundColor); if (isDark) { - // Dark background - use light colors return { text: contrastLevel === "AAA" ? colors.gray[50] : colors.gray[100], info: contrastLevel === "AAA" ? colors.sky[300] : colors.sky[400], @@ -45,7 +30,6 @@ export function getAccessibleTextColors( string: contrastLevel === "AAA" ? colors.lime[300] : colors.lime[400], }; } else { - // Light background - use dark colors return { text: contrastLevel === "AAA" ? colors.gray[900] : colors.gray[800], info: contrastLevel === "AAA" ? colors.sky[700] : colors.sky[600], @@ -59,9 +43,6 @@ export function getAccessibleTextColors( } } -/** - * Map WCAG contrast ratio to compliance level - */ export function getWCAGLevel( ratio: number, isLargeText: boolean = false, @@ -73,14 +54,11 @@ export function getWCAGLevel( } else { if (ratio >= 7) return "AAA"; if (ratio >= 4.5) return "AA"; - if (ratio >= 3) return "A"; // Only for large text + if (ratio >= 3) return "A"; return "FAIL"; } } -/** - * Get WCAG recommendations based on contrast ratio - */ export function getWCAGRecommendations(ratio: number): string[] { const recommendations: string[] = []; diff --git a/src/tokenizer/index.ts b/src/tokenizer/index.ts index 92cad6e..5c3d15f 100644 --- a/src/tokenizer/index.ts +++ b/src/tokenizer/index.ts @@ -354,11 +354,6 @@ export function addThemeRules(lexer: SimpleLexer, theme: Theme): void { } } -/** - * Create a lexer with theme-specific rules - * @param theme - The theme to use for tokenization - * @returns A lexer with theme-specific rules - */ export function createLexer(theme?: Theme): SimpleLexer { const lexer = new SimpleLexer(); @@ -465,12 +460,6 @@ export function convertLexerTokens( return lexerTokens.map(convertLexerToken); } -/** - * Tokenize a log line using theme-specific patterns - * @param line - The log line to tokenize - * @param theme - Optional theme to use for tokenization - * @returns A list of tokens - */ export function tokenize(line: string, theme?: Theme): TokenList { try { if (shouldUseDefaultToken(theme)) { @@ -643,12 +632,6 @@ export function applyTokenStyle(token: Token, theme: Theme): Token { return applyDefaultStyle(token, theme); } -/** - * Apply theme styling to tokens - * @param tokens - The tokens to style - * @param theme - The theme to apply - * @returns The styled tokens - */ export function applyTheme(tokens: TokenList, theme: Theme): TokenList { if (!theme || !theme.schema) { return tokens; diff --git a/src/types.ts b/src/types.ts index cda4204..b6b0be7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,3 @@ -/** - * Style codes that can be applied to text - */ export type StyleCode = | "bold" | "italic" @@ -10,21 +7,14 @@ export type StyleCode = | "reverse" | "strikethrough"; -/** - * Options for styling text in logs - */ export interface StyleOptions { - /** The color to apply (hex, rgb, or color name) */ color: string; - /** Optional style codes to apply */ + styleCodes?: StyleCode[]; - /** Format for HTML output */ + htmlStyleFormat?: "css" | "className"; } -/** - * Filter and validate style codes - */ export function filterStyleCodes(codes: string[] | undefined): StyleCode[] { if (!codes) return []; const validCodes: StyleCode[] = [ @@ -81,14 +71,9 @@ export interface Theme { export type ThemePreset = Theme; -/** - * Theme pair for automatic light/dark mode switching - * Allows specifying different themes for light and dark environments - */ export interface ThemePair { - /** Theme to use in light mode (theme name or Theme object) */ light: string | Theme; - /** Theme to use in dark mode (theme name or Theme object) */ + dark: string | Theme; } @@ -96,10 +81,10 @@ export interface LogsDXOptions { theme?: string | Theme | ThemePair; outputFormat?: "ansi" | "html"; htmlStyleFormat?: "css" | "className"; - escapeHtml?: boolean; // Whether to escape HTML in output (default: true) + escapeHtml?: boolean; debug?: boolean; customRules?: Record; - autoAdjustTerminal?: boolean; // Automatically adjust themes for terminal visibility + autoAdjustTerminal?: boolean; } export type LogLevel = "debug" | "info" | "warn" | "error"; diff --git a/src/utils/ascii.ts b/src/utils/ascii.ts index 728b42b..3a16161 100644 --- a/src/utils/ascii.ts +++ b/src/utils/ascii.ts @@ -12,12 +12,10 @@ export function textSync( verticalLayout?: string; }, ): string { - // For LogsDX, return the predefined ASCII art if (text === "LogsDX") { return LOGSDX_ASCII; } - // For other text, return as-is return text; } diff --git a/src/utils/boxen.ts b/src/utils/boxen.ts index ce88ce2..0edf338 100644 --- a/src/utils/boxen.ts +++ b/src/utils/boxen.ts @@ -84,14 +84,12 @@ export function boxen(text: string, options: BoxenOptions = {}): string { const result: string[] = []; - // Top margin for (let i = 0; i < margin.top; i++) { result.push(""); } const leftMargin = " ".repeat(margin.left); - // Top border const topBorder = options.title ? border.topLeft + ` ${options.title} ` + @@ -102,14 +100,12 @@ export function boxen(text: string, options: BoxenOptions = {}): string { : border.topLeft + border.horizontal.repeat(boxWidth) + border.topRight; result.push(leftMargin + topBorder); - // Top padding for (let i = 0; i < padding.top; i++) { result.push( leftMargin + border.vertical + " ".repeat(boxWidth) + border.vertical, ); } - // Content lines.forEach((line) => { const cleanLength = line.replace(/\x1B\[[0-9;]*m/g, "").length; const paddingRight = " ".repeat(Math.max(0, contentWidth - cleanLength)); @@ -124,14 +120,12 @@ export function boxen(text: string, options: BoxenOptions = {}): string { ); }); - // Bottom padding for (let i = 0; i < padding.bottom; i++) { result.push( leftMargin + border.vertical + " ".repeat(boxWidth) + border.vertical, ); } - // Bottom border result.push( leftMargin + border.bottomLeft + @@ -139,7 +133,6 @@ export function boxen(text: string, options: BoxenOptions = {}): string { border.bottomRight, ); - // Bottom margin for (let i = 0; i < margin.bottom; i++) { result.push(""); } diff --git a/src/utils/cli/constants.ts b/src/utils/cli/constants.ts deleted file mode 100644 index b45a080..0000000 --- a/src/utils/cli/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const HELP_FLAGS = ["--help", "-h"]; -export const VERSION_FLAGS = ["--version", "-v"]; - -export const BOOLEAN_FLAG_PREFIX = "no-"; diff --git a/src/utils/cli/types.ts b/src/utils/cli/types.ts deleted file mode 100644 index a0e8250..0000000 --- a/src/utils/cli/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface OptionDefinition { - flags: string; - description: string; - defaultValue?: any; -} - -export interface ParsedOptions { - [key: string]: any; -} - -export interface ArgumentDefinition { - name: string; - description: string; - required: boolean; -} diff --git a/src/utils/cli/utils.ts b/src/utils/cli/utils.ts deleted file mode 100644 index a38505a..0000000 --- a/src/utils/cli/utils.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { OptionDefinition } from "./types"; - -export function camelCase(str: string): string { - return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); -} - -export function findOption( - options: OptionDefinition[], - flag: string, -): OptionDefinition | undefined { - return options.find((o) => o.flags.includes(flag)); -} - -export function extractLongFlag(flags: string): string | null { - const match = flags.match(/--([a-z-]+)/); - return match ? match[1] : null; -} - -export function expectsValue(flags: string): boolean { - return flags.includes("<"); -} - -export function hasOptionalValue(flags: string): boolean { - return flags.includes("[") && !flags.startsWith("["); -} - -export function isBooleanFlag(flags: string): boolean { - return !expectsValue(flags) && !hasOptionalValue(flags); -} diff --git a/src/utils/colors.ts b/src/utils/colors.ts index 485a16a..73e128c 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -1,5 +1,4 @@ const styles = { - // Colors black: "\x1B[30m", red: "\x1B[31m", green: "\x1B[32m", @@ -10,7 +9,6 @@ const styles = { white: "\x1B[37m", gray: "\x1B[90m", - // Bright colors redBright: "\x1B[91m", greenBright: "\x1B[92m", yellowBright: "\x1B[93m", @@ -19,13 +17,11 @@ const styles = { cyanBright: "\x1B[96m", whiteBright: "\x1B[97m", - // Modifiers bold: "\x1B[1m", dim: "\x1B[2m", italic: "\x1B[3m", underline: "\x1B[4m", - // Reset reset: "\x1B[0m", }; @@ -41,7 +37,6 @@ function createChainableColor(appliedStyles: string[] = []): any { return `${prefix}${text}${styles.reset}`; }; - // Add all style properties Object.keys(styles).forEach((key) => { if (key === "reset") return; Object.defineProperty(fn, key, { @@ -59,7 +54,6 @@ function createChainableColor(appliedStyles: string[] = []): any { export const colors = createChainableColor(); -// Export individual functions for direct use export const red = createColorFunction(styles.red); export const green = createColorFunction(styles.green); export const yellow = createColorFunction(styles.yellow); diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts new file mode 100644 index 0000000..c357d51 --- /dev/null +++ b/src/utils/formatting.ts @@ -0,0 +1,192 @@ +// ============================================================================ +// ASCII Art (from utils/ascii.ts) +// ============================================================================ + +const LOGSDX_ASCII = ` + ╦ ┌─┐┌─┐┌─┐╔╦╗═╗ ╦ + ║ │ ││ ┬└─┐ ║║╔╩╦╝ + ╩═╝└─┘└─┘└─┘═╩╝╩ ╚═ +`; + +export function textSync( + text: string, + options?: { + font?: string; + horizontalLayout?: string; + verticalLayout?: string; + }, +): string { + if (text === "LogsDX") { + return LOGSDX_ASCII; + } + + return text; +} + +// ============================================================================ +// Boxen (from utils/boxen.ts) +// ============================================================================ + +interface BoxenOptions { + padding?: + | number + | { top?: number; bottom?: number; left?: number; right?: number }; + margin?: + | number + | { top?: number; bottom?: number; left?: number; right?: number }; + borderStyle?: "single" | "double" | "round" | "bold" | "classic"; + borderColor?: string; + backgroundColor?: string; + title?: string; +} + +const borderStyles = { + single: { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│", + }, + double: { + topLeft: "╔", + topRight: "╗", + bottomLeft: "╚", + bottomRight: "╝", + horizontal: "═", + vertical: "║", + }, + round: { + topLeft: "╭", + topRight: "╮", + bottomLeft: "╰", + bottomRight: "╯", + horizontal: "─", + vertical: "│", + }, + bold: { + topLeft: "┏", + topRight: "┓", + bottomLeft: "┗", + bottomRight: "┛", + horizontal: "━", + vertical: "┃", + }, + classic: { + topLeft: "+", + topRight: "+", + bottomLeft: "+", + bottomRight: "+", + horizontal: "-", + vertical: "|", + }, +}; + +function normalizePadding( + value: + | number + | { top?: number; bottom?: number; left?: number; right?: number } + | undefined, +): { top: number; bottom: number; left: number; right: number } { + if (typeof value === "number") { + return { top: value, bottom: value, left: value, right: value }; + } + return { + top: value?.top || 0, + bottom: value?.bottom || 0, + left: value?.left || 0, + right: value?.right || 0, + }; +} + +export function boxen(text: string, options: BoxenOptions = {}): string { + const border = borderStyles[options.borderStyle || "single"]; + const padding = normalizePadding(options.padding); + const margin = normalizePadding(options.margin); + + const lines = text.split("\n"); + const contentWidth = Math.max( + ...lines.map((line) => line.replace(/\x1B\[[0-9;]*m/g, "").length), + ); + const boxWidth = contentWidth + padding.left + padding.right; + + const result: string[] = []; + + for (let i = 0; i < margin.top; i++) { + result.push(""); + } + + const leftMargin = " ".repeat(margin.left); + + const topBorder = options.title + ? border.topLeft + + ` ${options.title} ` + + border.horizontal.repeat( + Math.max(0, boxWidth - options.title.length - 2), + ) + + border.topRight + : border.topLeft + border.horizontal.repeat(boxWidth) + border.topRight; + result.push(leftMargin + topBorder); + + for (let i = 0; i < padding.top; i++) { + result.push( + leftMargin + border.vertical + " ".repeat(boxWidth) + border.vertical, + ); + } + + lines.forEach((line) => { + const cleanLength = line.replace(/\x1B\[[0-9;]*m/g, "").length; + const paddingRight = " ".repeat(Math.max(0, contentWidth - cleanLength)); + result.push( + leftMargin + + border.vertical + + " ".repeat(padding.left) + + line + + paddingRight + + " ".repeat(padding.right) + + border.vertical, + ); + }); + + for (let i = 0; i < padding.bottom; i++) { + result.push( + leftMargin + border.vertical + " ".repeat(boxWidth) + border.vertical, + ); + } + + result.push( + leftMargin + + border.bottomLeft + + border.horizontal.repeat(boxWidth) + + border.bottomRight, + ); + + for (let i = 0; i < margin.bottom; i++) { + result.push(""); + } + + return result.join("\n"); +} + +// ============================================================================ +// Gradient (from utils/gradient.ts) +// ============================================================================ + +export function gradient(colors: string[]): { + (text: string): string; + multiline(text: string): string; +} { + const applyGradient = (text: string) => `\x1B[36m${text}\x1B[0m`; + + applyGradient.multiline = (text: string) => { + return text + .split("\n") + .map((line) => `\x1B[36m${line}\x1B[0m`) + .join("\n"); + }; + + return applyGradient; +} + +export default { textSync, boxen, gradient }; diff --git a/src/utils/gradient.ts b/src/utils/gradient.ts index 74d2afc..40ee06e 100644 --- a/src/utils/gradient.ts +++ b/src/utils/gradient.ts @@ -2,7 +2,6 @@ export function gradient(colors: string[]): { (text: string): string; multiline(text: string): string; } { - // Use cyan as default gradient color (simple implementation) const applyGradient = (text: string) => `\x1B[36m${text}\x1B[0m`; applyGradient.multiline = (text: string) => { diff --git a/src/utils/prompts.ts b/src/utils/prompts.ts index 8025063..7cd47a2 100644 --- a/src/utils/prompts.ts +++ b/src/utils/prompts.ts @@ -1,5 +1,4 @@ import * as readline from "readline"; -import colors from "./colors"; import { logger } from "./logger"; interface BasePrompt { diff --git a/src/utils/spinner.ts b/src/utils/spinner.ts index bbbf0a8..b4bb0f9 100644 --- a/src/utils/spinner.ts +++ b/src/utils/spinner.ts @@ -1,7 +1,3 @@ -/** - * Lightweight spinner utility to replace ora - */ - export interface Spinner { start(): Spinner; succeed(text?: string): Spinner; @@ -30,7 +26,7 @@ export function spinner(initialText: string): Spinner { if (isSpinning) return instance; isSpinning = true; - process.stdout.write("\x1B[?25l"); // Hide cursor + process.stdout.write("\x1B[?25l"); interval = setInterval(() => { const frame = frames[frameIndex]; @@ -61,8 +57,8 @@ export function spinner(initialText: string): Spinner { interval = null; } if (isSpinning) { - process.stdout.write("\r\x1B[K"); // Clear line - process.stdout.write("\x1B[?25h"); // Show cursor + process.stdout.write("\r\x1B[K"); + process.stdout.write("\x1B[?25h"); isSpinning = false; } return instance; diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts new file mode 100644 index 0000000..95be1c8 --- /dev/null +++ b/src/utils/terminal.ts @@ -0,0 +1,154 @@ +import colors from "./colors"; + +// ============================================================================ +// Logger (from utils/logger.ts) +// ============================================================================ + +export const logger = { + info(message: string) { + console.log(colors.blue("ℹ"), message); + }, + + success(message: string) { + console.log(colors.green("✔"), message); + }, + + warn(message: string) { + console.log(colors.yellow("⚠"), message); + }, + + error(message: string) { + console.error(colors.red("✖"), message); + }, + + debug(message: string) { + console.log(colors.gray("⚙"), message); + }, +}; + +// ============================================================================ +// Spinner (from utils/spinner.ts) +// ============================================================================ + +export interface Spinner { + start(): Spinner; + succeed(text?: string): Spinner; + fail(text?: string): Spinner; + stop(): Spinner; + text: string; +} + +const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +export function spinner(initialText: string): Spinner { + let text = initialText; + let frameIndex = 0; + let interval: ReturnType | null = null; + let isSpinning = false; + + const instance: Spinner = { + get text() { + return text; + }, + set text(value: string) { + text = value; + }, + + start() { + if (isSpinning) return instance; + + isSpinning = true; + process.stdout.write("\x1B[?25l"); + + interval = setInterval(() => { + const frame = frames[frameIndex]; + frameIndex = (frameIndex + 1) % frames.length; + process.stdout.write(`\r\x1B[36m${frame}\x1B[0m ${text}`); + }, 80); + + return instance; + }, + + succeed(successText?: string) { + instance.stop(); + const message = successText || text; + process.stdout.write(`\r\x1B[32m✔\x1B[0m ${message}\n`); + return instance; + }, + + fail(failText?: string) { + instance.stop(); + const message = failText || text; + process.stdout.write(`\r\x1B[31m✖\x1B[0m ${message}\n`); + return instance; + }, + + stop() { + if (interval) { + clearInterval(interval); + interval = null; + } + if (isSpinning) { + process.stdout.write("\r\x1B[K"); + process.stdout.write("\x1B[?25h"); + isSpinning = false; + } + return instance; + }, + }; + + return instance; +} + +// ============================================================================ +// Progress Bar (from utils/progress.ts) +// ============================================================================ + +export interface ProgressBar { + start(total: number, startValue: number): void; + update(value: number): void; + stop(): void; +} + +export function createProgressBar(total: number): ProgressBar { + let currentValue = 0; + let startTime = Date.now(); + let isRunning = false; + + const render = (value: number) => { + const percentage = Math.floor((value / total) * 100); + const barLength = 40; + const filledLength = Math.floor((percentage / 100) * barLength); + const bar = "█".repeat(filledLength) + "░".repeat(barLength - filledLength); + const elapsed = Date.now() - startTime; + const duration = `${Math.floor(elapsed / 1000)}s`; + + process.stdout.write( + `\r ${bar} | ${percentage}% | ${value}/${total} lines | ${duration}`, + ); + }; + + return { + start(totalValue: number, startValue: number) { + total = totalValue; + currentValue = startValue; + startTime = Date.now(); + isRunning = true; + render(currentValue); + }, + + update(value: number) { + if (!isRunning) return; + currentValue = value; + render(currentValue); + }, + + stop() { + if (!isRunning) return; + isRunning = false; + process.stdout.write("\n"); + }, + }; +} + +export default { logger, spinner, createProgressBar }; diff --git a/tests/unit/cli/index.test.ts b/tests/unit/cli/index.test.ts index 5b0404b..e63d619 100644 --- a/tests/unit/cli/index.test.ts +++ b/tests/unit/cli/index.test.ts @@ -5,8 +5,6 @@ import fs from "fs"; import os from "os"; import path from "path"; -// Using the imported CliOptions type from ./types - describe("parseArgs", () => { test("should parse basic theme argument", () => { const args = ["--theme", "dracula"]; diff --git a/tests/unit/cli/theme/generator.test.ts b/tests/unit/cli/theme/generator.test.ts index ec85f75..0a5ca3d 100644 --- a/tests/unit/cli/theme/generator.test.ts +++ b/tests/unit/cli/theme/generator.test.ts @@ -1,13 +1,28 @@ -import { describe, it, expect } from "bun:test"; +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; import { generateTemplateFromAnswers, validateColorInput, generatePatternFromPreset, -} from "../../../../src/cli/theme/generator"; + listColorPalettesCommand, + listPatternPresetsCommand, +} from "../../../../src/cli/theme-gen"; import { COLOR_PALETTES, PATTERN_PRESETS, -} from "../../../../src/themes/template"; +} from "../../../../src/themes/presets"; + +const originalLog = console.log; +const originalWarn = console.warn; + +beforeEach(() => { + console.log = mock(() => {}); + console.warn = mock(() => {}); +}); + +afterEach(() => { + console.log = originalLog; + console.warn = originalWarn; +}); describe("Theme Generator", () => { describe("validateColorInput", () => { @@ -22,13 +37,35 @@ describe("Theme Generator", () => { it("should validate rgb colors", () => { expect(validateColorInput("rgb(255, 0, 0)")).toBe(true); expect(validateColorInput("rgba(255, 0, 0, 0.5)")).toBe(true); - expect(validateColorInput("rgb(256, 0, 0)")).toBe(true); // Still valid format + expect(validateColorInput("rgb(256, 0, 0)")).toBe(true); }); it("should validate named colors", () => { expect(validateColorInput("red")).toBe(true); expect(validateColorInput("blue")).toBe(true); - expect(validateColorInput("notacolor")).toBe(true); // We accept any string as a named color + expect(validateColorInput("notacolor")).toBe(true); + }); + + it("should reject empty or invalid strings", () => { + expect(validateColorInput("")).toBe(false); + expect(validateColorInput(" ")).toBe(false); + expect(validateColorInput(null as any)).toBe(false); + expect(validateColorInput(undefined as any)).toBe(false); + }); + + it("should reject hex without hash", () => { + expect(validateColorInput("aabbcc")).toBe(false); + expect(validateColorInput("123456")).toBe(false); + }); + + it("should validate various named colors", () => { + expect(validateColorInput("green")).toBe(true); + expect(validateColorInput("yellow")).toBe(true); + expect(validateColorInput("cyan")).toBe(true); + expect(validateColorInput("magenta")).toBe(true); + expect(validateColorInput("white")).toBe(true); + expect(validateColorInput("black")).toBe(true); + expect(validateColorInput("gray")).toBe(true); }); }); @@ -56,6 +93,20 @@ describe("Theme Generator", () => { }); }); + it("should generate UUID pattern", () => { + const pattern = generatePatternFromPreset("uuid"); + expect(pattern).toHaveProperty("name", "uuid"); + expect(pattern).toHaveProperty("pattern"); + expect(pattern).toHaveProperty("options"); + }); + + it("should generate URL pattern", () => { + const pattern = generatePatternFromPreset("url"); + expect(pattern).toHaveProperty("name", "url"); + expect(pattern).toHaveProperty("pattern"); + expect(pattern).toHaveProperty("options"); + }); + it("should return empty object for unknown preset", () => { const pattern = generatePatternFromPreset("unknown"); expect(pattern).toEqual({}); @@ -120,14 +171,108 @@ describe("Theme Generator", () => { expect(theme.schema.matchWords).toHaveProperty("false"); expect(theme.schema.matchWords).toHaveProperty("null"); - // Check that matchStartsWith and matchEndsWith exist expect(theme.schema.matchStartsWith).toBeDefined(); expect(theme.schema.matchEndsWith).toBeDefined(); - // Check the bracket properties expect(theme.schema.matchStartsWith?.["["]).toBeDefined(); expect(theme.schema.matchEndsWith?.["]"]).toBeDefined(); }); + + it("should handle httpStatus feature", () => { + const theme = generateTemplateFromAnswers({ + name: "http-theme", + mode: "dark", + palette: "github-dark", + features: ["httpStatus"], + patterns: [], + }); + + expect(theme.schema.matchWords).toHaveProperty("200"); + expect(theme.schema.matchWords).toHaveProperty("404"); + expect(theme.schema.matchWords).toHaveProperty("500"); + }); + + it("should handle custom patterns in answers", () => { + const theme = generateTemplateFromAnswers({ + name: "custom-pattern-theme", + mode: "dark", + palette: "github-dark", + features: [], + patterns: [], + customPatterns: [ + { + name: "test-pattern", + pattern: "\\d+", + color: "#ff0000", + styleCodes: ["bold"], + }, + ], + }); + + expect(theme.schema).toBeDefined(); + }); + + it("should handle custom words in answers", () => { + const theme = generateTemplateFromAnswers({ + name: "custom-words-theme", + mode: "dark", + palette: "github-dark", + features: [], + patterns: [], + customWords: { + CUSTOM: { colorRole: "error", styleCodes: ["bold"] }, + }, + }); + + expect(theme.schema).toBeDefined(); + }); + + it("should use themeName field when name is not provided", () => { + const theme = generateTemplateFromAnswers({ + themeName: "fallback-name", + mode: "dark", + palette: "github-dark", + features: [], + patterns: [], + }); + + expect(theme.name).toBe("fallback-name"); + }); + + it("should use patternPresets field when patterns is not provided", () => { + const theme = generateTemplateFromAnswers({ + name: "preset-theme", + mode: "dark", + palette: "github-dark", + features: [], + patternPresets: ["log-levels"], + }); + + expect(theme.schema.matchPatterns).toBeDefined(); + }); + + it("should handle empty mode", () => { + const theme = generateTemplateFromAnswers({ + name: "no-mode", + palette: "github-dark", + features: [], + patterns: [], + }); + + expect(theme.name).toBe("no-mode"); + }); + + it("should add log-levels to patterns when logLevels feature is present", () => { + const theme = generateTemplateFromAnswers({ + name: "log-levels-feature", + mode: "dark", + palette: "github-dark", + features: ["logLevels"], + patterns: [], + }); + + expect(theme.schema.matchWords).toHaveProperty("ERROR"); + }); }); describe("COLOR_PALETTES", () => { @@ -170,4 +315,49 @@ describe("Theme Generator", () => { }); }); }); + + describe("listColorPalettesCommand", () => { + it("should display all color palettes", () => { + listColorPalettesCommand(); + + expect(console.log).toHaveBeenCalled(); + }); + + it("should display palette details", () => { + listColorPalettesCommand(); + + const calls = (console.log as ReturnType).mock.calls; + const allOutput = calls.map((call) => call.join(" ")).join("\n"); + + expect(allOutput).toContain("github-dark"); + expect(allOutput).toContain("github-light"); + }); + }); + + describe("listPatternPresetsCommand", () => { + it("should display all pattern presets", () => { + listPatternPresetsCommand(); + + expect(console.log).toHaveBeenCalled(); + }); + + it("should display presets grouped by category", () => { + listPatternPresetsCommand(); + + const calls = (console.log as ReturnType).mock.calls; + const allOutput = calls.map((call) => call.join(" ")).join("\n"); + + expect(allOutput).toContain("log-levels"); + }); + + it("should show pattern and word match counts", () => { + listPatternPresetsCommand(); + + const calls = (console.log as ReturnType).mock.calls; + const allOutput = calls.map((call) => call.join(" ")).join("\n"); + + expect(allOutput).toContain("patterns"); + expect(allOutput).toContain("word matches"); + }); + }); }); diff --git a/tests/unit/cli/theme/transporter.test.ts b/tests/unit/cli/theme/transporter.test.ts index 05e8d25..1907c8d 100644 --- a/tests/unit/cli/theme/transporter.test.ts +++ b/tests/unit/cli/theme/transporter.test.ts @@ -1,29 +1,32 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "fs"; import { join } from "path"; import { exportThemeToFile, importThemeFromFile, listThemeFiles, -} from "../../../../src/cli/theme/transporter"; + getThemeFiles, + listThemeFilesCommand, +} from "../../../../src/cli/theme-gen"; import type { Theme } from "../../../../src/types"; -// Create a temporary test directory +const originalLog = console.log; + const TEST_DIR = join(process.cwd(), ".test-themes"); describe("Theme Transporter", () => { beforeEach(() => { - // Create test directory if (!existsSync(TEST_DIR)) { mkdirSync(TEST_DIR, { recursive: true }); } + console.log = mock(() => {}); }); afterEach(() => { - // Clean up test directory if (existsSync(TEST_DIR)) { rmSync(TEST_DIR, { recursive: true, force: true }); } + console.log = originalLog; }); const sampleTheme: Theme = { @@ -121,7 +124,6 @@ describe("Theme Transporter", () => { it("should validate imported theme", () => { const filePath = join(TEST_DIR, "invalid-theme.json"); const invalidTheme = { - // Missing required fields description: "Invalid theme", }; writeFileSync(filePath, JSON.stringify(invalidTheme, null, 2)); @@ -144,7 +146,6 @@ describe("Theme Transporter", () => { describe("listThemeFiles", () => { it("should list theme files in directory", () => { - // Create some theme files writeFileSync(join(TEST_DIR, "theme1.json"), JSON.stringify(sampleTheme)); writeFileSync(join(TEST_DIR, "theme2.json"), JSON.stringify(sampleTheme)); writeFileSync( @@ -195,26 +196,20 @@ describe("Theme Transporter", () => { it("should round-trip theme through export and import", () => { const filePath = join(TEST_DIR, "round-trip.json"); - // Export exportThemeToFile(sampleTheme, filePath, "json"); - // Import const imported = importThemeFromFile(filePath); - // Verify expect(imported).toEqual(sampleTheme); }); it("should handle TypeScript round-trip", () => { const filePath = join(TEST_DIR, "round-trip.ts"); - // Export as TypeScript exportThemeToFile(sampleTheme, filePath, "typescript"); - // Import const imported = importThemeFromFile(filePath); - // Verify core properties (TS export adds formatting) expect(imported.name).toBe(sampleTheme.name); expect(imported.description).toBe(sampleTheme.description); expect(imported.mode).toBe(sampleTheme.mode); @@ -223,4 +218,79 @@ describe("Theme Transporter", () => { ); }); }); + + describe("getThemeFiles", () => { + it("should be the same as listThemeFiles", () => { + expect(getThemeFiles).toBe(listThemeFiles); + }); + }); + + describe("listThemeFilesCommand", () => { + it("should display no theme files message when directory is empty", () => { + listThemeFilesCommand(TEST_DIR); + + expect(console.log).toHaveBeenCalled(); + }); + + it("should list theme files with metadata", () => { + const themeFile = join(TEST_DIR, "test.theme.json"); + writeFileSync(themeFile, JSON.stringify(sampleTheme, null, 2)); + + listThemeFilesCommand(TEST_DIR); + + expect(console.log).toHaveBeenCalled(); + const calls = (console.log as ReturnType).mock.calls; + const allOutput = calls.map((call) => call.join(" ")).join("\n"); + + expect(allOutput).toContain("test-theme"); + expect(allOutput).toContain("A test theme"); + }); + + it("should handle invalid JSON theme files", () => { + const invalidFile = join(TEST_DIR, "invalid.theme.json"); + writeFileSync(invalidFile, "{ invalid json }"); + + listThemeFilesCommand(TEST_DIR); + + expect(console.log).toHaveBeenCalled(); + const calls = (console.log as ReturnType).mock.calls; + const allOutput = calls.map((call) => call.join(" ")).join("\n"); + + expect(allOutput).toContain("Error"); + }); + + it("should display exportedAt timestamp if present", () => { + const themeWithExport = { + ...sampleTheme, + exportedAt: new Date().toISOString(), + }; + const themeFile = join(TEST_DIR, "exported.theme.json"); + writeFileSync(themeFile, JSON.stringify(themeWithExport, null, 2)); + + listThemeFilesCommand(TEST_DIR); + + expect(console.log).toHaveBeenCalled(); + const calls = (console.log as ReturnType).mock.calls; + const allOutput = calls.map((call) => call.join(" ")).join("\n"); + + expect(allOutput).toContain("Exported"); + }); + + it("should handle themes without description", () => { + const themeWithoutDesc = { + name: "minimal-theme", + schema: {}, + }; + const themeFile = join(TEST_DIR, "minimal.theme.json"); + writeFileSync(themeFile, JSON.stringify(themeWithoutDesc, null, 2)); + + listThemeFilesCommand(TEST_DIR); + + expect(console.log).toHaveBeenCalled(); + const calls = (console.log as ReturnType).mock.calls; + const allOutput = calls.map((call) => call.join(" ")).join("\n"); + + expect(allOutput).toContain("minimal-theme"); + }); + }); }); diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index 8e6d5a6..9e7248b 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -3,11 +3,9 @@ import stripAnsi from "strip-ansi"; import { LogsDX, getLogsDX } from "../../src/index"; describe("LogsDX", () => { - // Save original console methods const originalConsoleWarn = console.warn; let consoleWarnings: string[] = []; - // Setup and teardown beforeEach(() => { LogsDX.resetInstance(); consoleWarnings = []; @@ -33,9 +31,8 @@ describe("LogsDX", () => { }); test("applies custom options when provided", () => { - // First reset to ensure we're starting fresh LogsDX.resetInstance(); - // Then create with custom options + const customInstance = LogsDX.getInstance({ theme: "oh-my-zsh" }); expect(customInstance.getCurrentTheme().name).toBe("oh-my-zsh"); }); @@ -64,7 +61,6 @@ describe("LogsDX", () => { const line = "test line"; const result = instance.processLine(line); - // Just verify it returns a string expect(typeof result).toBe("string"); }); @@ -108,7 +104,6 @@ describe("LogsDX", () => { expect(tokens).toBeInstanceOf(Array); expect(tokens.length).toBeGreaterThan(0); - // The entire string should be tokenized const totalContent = tokens.map((t) => t.content).join(""); expect(totalContent).toBe("test line"); }); @@ -117,7 +112,7 @@ describe("LogsDX", () => { describe("setTheme", () => { test("sets theme by name", () => { const instance = LogsDX.getInstance(); - // Use a theme we know exists + const result = instance.setTheme("oh-my-zsh"); expect(result).toBe(true); expect(instance.getCurrentTheme().name).toBe("oh-my-zsh"); @@ -138,9 +133,9 @@ describe("LogsDX", () => { test("returns true even for invalid theme (fails silently)", () => { const instance = LogsDX.getInstance({ debug: true }); - // @ts-expect-error - Testing invalid theme object + expect(instance.setTheme({ invalid: "theme" })).toBe(true); - // Should default to 'none' theme when invalid + expect(instance.getCurrentTheme().name).toBe("none"); }); }); @@ -199,57 +194,45 @@ describe("LogsDX", () => { }); describe("ANSI theming integration", () => { - // Save original console methods const originalConsoleWarn = console.warn; beforeEach(() => { - // Reset the instance before each test to ensure clean state LogsDX.resetInstance(); - // Silence warnings during these tests + console.warn = () => {}; }); afterEach(() => { - // Restore console.warn console.warn = originalConsoleWarn; }); test("applies theme colors to ANSI output", () => { - // Create a fresh instance with explicit ANSI output format const instance = LogsDX.getInstance({ theme: "oh-my-zsh", - outputFormat: "ansi", // Explicitly set output format to ansi + outputFormat: "ansi", }); - // Verify the output format is set correctly expect(instance.getCurrentOutputFormat()).toBe("ansi"); - // Process lines with different log levels const errorLine = instance.processLine("ERROR: This is an error message"); const warnLine = instance.processLine("WARN: This is a warning message"); const infoLine = instance.processLine("INFO: This is an info message"); - // Strip ANSI codes and check the plain text content expect(stripAnsi(errorLine)).toBe("ERROR: This is an error message"); expect(stripAnsi(warnLine)).toBe("WARN: This is a warning message"); expect(stripAnsi(infoLine)).toBe("INFO: This is an info message"); - // Test that different themes produce different output LogsDX.resetInstance(); const dracula = LogsDX.getInstance({ theme: "dracula", - outputFormat: "ansi", // Explicitly set output format to ansi + outputFormat: "ansi", }); const draculaError = dracula.processLine("ERROR: This is an error message"); - // The original text should be preserved expect(stripAnsi(draculaError)).toBe("ERROR: This is an error message"); }); - // Helper function to strip ANSI escape codes for testing - test("applies style codes like bold and italic", () => { - // Create a fresh instance with explicit ANSI output format and a custom theme const instance = LogsDX.getInstance({ theme: { name: "test-theme", @@ -263,14 +246,11 @@ describe("ANSI theming integration", () => { outputFormat: "ansi", }); - // Process a line with a keyword that should have styling const errorLine = instance.processLine("ERROR: Critical failure"); - // Strip ANSI codes and check the plain text content const plainText = stripAnsi(errorLine); expect(plainText).toBe("ERROR: Critical failure"); - // Verify the output format is set correctly expect(instance.getCurrentOutputFormat()).toBe("ansi"); }); }); diff --git a/tests/unit/renderer/constants.test.ts b/tests/unit/renderer/constants.test.ts index 6e50320..869ab85 100644 --- a/tests/unit/renderer/constants.test.ts +++ b/tests/unit/renderer/constants.test.ts @@ -45,7 +45,6 @@ describe("Color Support Detection", () => { (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY = true; delete process.env.TERM; - // In Bun runtime, colors are supported even without TERM const expectedResult = typeof Bun !== "undefined" ? true : false; expect(supportsColors()).toBe(expectedResult); }); diff --git a/tests/unit/renderer/index.test.ts b/tests/unit/renderer/index.test.ts index 799aba8..8c338b0 100644 --- a/tests/unit/renderer/index.test.ts +++ b/tests/unit/renderer/index.test.ts @@ -17,7 +17,7 @@ describe("Renderer", () => { describe("renderLine", () => { test("renders a simple line with default options", () => { const result = renderLine("test line"); - // We need to check for individual characters since they're styled separately + expect(result).toContain("t"); expect(result).toContain("e"); expect(result).toContain("s"); @@ -29,7 +29,7 @@ describe("Renderer", () => { outputFormat: "html", htmlStyleFormat: "css", }); - // Check for HTML span tags and the content + expect(result).toContain(""); @@ -40,7 +40,7 @@ describe("Renderer", () => { outputFormat: "html", htmlStyleFormat: "className", }); - // Check for HTML span tags with class names + expect(result).toContain(""); @@ -87,9 +87,9 @@ describe("Renderer", () => { }, }, ]; - const result = tokensToString(tokens, true); // Force colors for testing + const result = tokensToString(tokens, true); expect(result).toContain("error"); - expect(result).toContain("\x1b[31m"); // Red color ANSI code + expect(result).toContain("\x1b[31m"); }); test("applies multiple style codes to tokens", () => { @@ -104,10 +104,10 @@ describe("Renderer", () => { }, }, ]; - const result = tokensToString(tokens, true); // Force colors for testing + const result = tokensToString(tokens, true); expect(result).toContain("important"); - expect(result).toContain("\x1b[1m"); // Bold - expect(result).toContain("\x1b[4m"); // Underline + expect(result).toContain("\x1b[1m"); + expect(result).toContain("\x1b[4m"); }); }); @@ -193,7 +193,7 @@ describe("Renderer", () => { test("applyColor adds ANSI color codes", () => { const result = applyColor("text", "red"); expect(result).toContain("text"); - // Since the implementation returns objects, let's check the structure + expect(typeof result).toBe("string"); expect(result).toMatch(/text/); }); @@ -221,9 +221,185 @@ describe("Renderer", () => { test("applyBackgroundColor adds ANSI background color", () => { const result = applyBackgroundColor("text", "blue"); expect(result).toContain("text"); - // Since the implementation returns objects, let's check the structure + expect(typeof result).toBe("string"); expect(result).toMatch(/text/); }); }); + + describe("edge cases", () => { + test("handles tokens with trimmed spaces without originalLength", () => { + const tokens: TokenList = [ + { + content: " ", + metadata: { + matchType: "spaces", + trimmed: true, + }, + }, + ]; + const result = tokensToString(tokens); + expect(result).toBe(""); + }); + + test("handles tokens with trimmed metadata and originalLength", () => { + const tokens: TokenList = [ + { + content: " ", + metadata: { + matchType: "spaces", + trimmed: true, + originalLength: 2, + }, + }, + ]; + const result = tokensToString(tokens); + expect(result).toBe(" "); + }); + + test("handles tokens with all style codes", () => { + const tokens: TokenList = [ + { + content: "text", + metadata: { + style: { + color: "#ff0000", + styleCodes: ["bold", "italic", "underline", "dim"], + }, + }, + }, + ]; + const resultAnsi = tokensToString(tokens, true); + expect(resultAnsi).toContain("text"); + + const resultHtml = tokensToHtml(tokens); + expect(resultHtml).toContain("text"); + expect(resultHtml).toContain("font-weight: bold"); + expect(resultHtml).toContain("font-style: italic"); + expect(resultHtml).toContain("text-decoration: underline"); + }); + + test("handles carriage return in HTML", () => { + const tokens: TokenList = [ + { + content: "\r", + metadata: { matchType: "carriage-return" }, + }, + ]; + const result = tokensToHtml(tokens); + expect(result).toBe(""); + }); + + test("handles tab tokens in HTML", () => { + const tokens: TokenList = [ + { + content: "\t", + metadata: { matchType: "tab" }, + }, + ]; + const result = tokensToHtml(tokens); + expect(result).toContain(" "); + }); + + test("handles background color in styles", () => { + const tokens: TokenList = [ + { + content: "text", + metadata: { + style: { + color: "#ff0000", + backgroundColor: "#000000", + }, + }, + }, + ]; + const result = tokensToString(tokens, true); + expect(result).toContain("text"); + }); + + test("handles tokens without style metadata", () => { + const tokens: TokenList = [ + { + content: "plain text", + metadata: {}, + }, + ]; + const result = tokensToString(tokens, true); + expect(result).toBe("plain text"); + }); + + test("handles single space match type", () => { + const tokens: TokenList = [ + { + content: " ", + metadata: { + matchType: "space", + }, + }, + ]; + const result = tokensToString(tokens); + expect(result).toBe(" "); + }); + + test("handles mixed token types", () => { + const tokens: TokenList = [ + { + content: "text", + metadata: { matchType: "word" }, + }, + { + content: " ", + metadata: { matchType: "space" }, + }, + { + content: "more", + metadata: { matchType: "word" }, + }, + ]; + const result = tokensToString(tokens); + expect(result).toBe("text more"); + }); + + test("handles trimmed spaces in HTML", () => { + const tokens: TokenList = [ + { + content: " ", + metadata: { + matchType: "spaces", + trimmed: true, + }, + }, + ]; + const result = tokensToHtml(tokens); + expect(result).toBe(" "); + }); + + test("handles trimmed token with TRIMMED_SPACE_MATCH_TYPES in HTML", () => { + const tokens: TokenList = [ + { + content: " ", + metadata: { + matchType: "spaces", + trimmed: true, + originalLength: 2, + }, + }, + ]; + const result = tokensToHtml(tokens); + expect(result).toBe(" "); + }); + + test("handles spaces match type in HTML", () => { + const tokens: TokenList = [ + { + content: " ", + metadata: { + matchType: "spaces", + }, + }, + ]; + const result = tokensToHtml(tokens); + expect(result).toContain(" "); + }); + }); }); diff --git a/tests/unit/schema/validator.test.ts b/tests/unit/schema/validator.test.ts index 860cdd6..1d32bd5 100644 --- a/tests/unit/schema/validator.test.ts +++ b/tests/unit/schema/validator.test.ts @@ -8,6 +8,7 @@ import { validateThemeSafe, convertTokenSchemaToJson, convertThemeSchemaToJson, + createThemeValidationError, } from "../../../src/schema/validator"; import { z } from "zod"; @@ -125,7 +126,6 @@ describe("Schema Validator", () => { test("throws on invalid theme", () => { const invalidTheme = { name: "Dark Theme", - // Missing schema property }; expect(() => validateTheme(invalidTheme)).toThrow(); @@ -153,7 +153,6 @@ describe("Schema Validator", () => { test("returns error for invalid theme", () => { const invalidTheme = { name: "Dark Theme", - // Missing schema property }; const result = validateThemeSafe(invalidTheme); @@ -167,11 +166,9 @@ describe("Schema Validator", () => { const jsonSchema = convertTokenSchemaToJson(); expect(jsonSchema).toHaveProperty("$schema"); - // The structure might be different than expected, check what's actually returned + expect(typeof jsonSchema).toBe("object"); - // Looking at the implementation, the name is passed as an option to zodToJsonSchema - // but it might be stored differently in the output const hasNameReference = JSON.stringify(jsonSchema).includes("Token"); expect(hasNameReference).toBe(true); }); @@ -182,13 +179,55 @@ describe("Schema Validator", () => { const jsonSchema = convertThemeSchemaToJson(); expect(jsonSchema).toHaveProperty("$schema"); - // The structure might be different than expected, check what's actually returned + expect(typeof jsonSchema).toBe("object"); - // Looking at the implementation, the name is passed as an option to zodToJsonSchema - // but it might be stored differently in the output const hasNameReference = JSON.stringify(jsonSchema).includes("Theme"); expect(hasNameReference).toBe(true); }); }); + + describe("createThemeValidationError", () => { + test("returns Error object as is when given Error", () => { + const originalError = new Error("Custom error message"); + + const result = createThemeValidationError(originalError); + + expect(result).toBe(originalError); + expect(result.message).toBe("Custom error message"); + }); + + test("converts string to Error when given string", () => { + const errorString = "Something went wrong"; + + const result = createThemeValidationError(errorString); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe("Something went wrong"); + }); + + test("converts number to Error when given number", () => { + const errorNumber = 404; + + const result = createThemeValidationError(errorNumber); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe("404"); + }); + + test("formats ZodError with validation message", () => { + const invalidTheme = { + name: "test", + }; + + try { + validateTheme(invalidTheme); + } catch (error) { + const result = createThemeValidationError(error); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toContain("Theme validation failed"); + } + }); + }); }); diff --git a/tests/unit/themes/index.test.ts b/tests/unit/themes/index.test.ts index 0acdf84..727b553 100644 --- a/tests/unit/themes/index.test.ts +++ b/tests/unit/themes/index.test.ts @@ -8,11 +8,9 @@ import { import { THEMES, DEFAULT_THEME } from "../../../src/themes/constants"; describe("Theme Management", () => { - // Save original console.log const originalConsoleLog = console.log; let consoleOutput: string[] = []; - // Mock console.log before each test beforeEach(() => { consoleOutput = []; console.log = (message: string) => { @@ -20,7 +18,6 @@ describe("Theme Management", () => { }; }); - // Restore console.log after each test afterEach(() => { console.log = originalConsoleLog; }); @@ -72,7 +69,6 @@ describe("Theme Management", () => { registerTheme(testTheme); - // Verify theme was registered expect(getTheme("test-theme")).toEqual(testTheme); expect(getThemeNames()).toContain("test-theme"); expect(getAllThemes()["test-theme"]).toEqual(testTheme); @@ -106,7 +102,6 @@ describe("Theme Management", () => { registerTheme(firstTheme); registerTheme(secondTheme); - // Should have the second theme expect(getTheme("overwrite-test")).toEqual(secondTheme); expect(getTheme("overwrite-test").description).toBe("Second version"); }); diff --git a/tests/unit/themes/template.test.ts b/tests/unit/themes/presets.test.ts similarity index 96% rename from tests/unit/themes/template.test.ts rename to tests/unit/themes/presets.test.ts index 51bf1d1..682497a 100644 --- a/tests/unit/themes/template.test.ts +++ b/tests/unit/themes/presets.test.ts @@ -10,7 +10,7 @@ import { generateTemplate, COLOR_PALETTES, PATTERN_PRESETS, -} from "../../../src/themes/template"; +} from "../../../src/themes/presets"; describe("Theme Generator Schemas", () => { test("colorPaletteSchema should validate valid palette", () => { @@ -239,7 +239,7 @@ describe("Theme Generation", () => { const theme = generateTemplate(config); expect(theme.schema.matchWords?.CUSTOM).toBeDefined(); - expect(theme.schema.matchWords?.CUSTOM.color).toBe("#d1242f"); // github-light error color + expect(theme.schema.matchWords?.CUSTOM.color).toBe("#d1242f"); expect(theme.schema.matchWords?.CUSTOM.styleCodes).toEqual(["bold"]); }); @@ -252,11 +252,9 @@ describe("Theme Generation", () => { const theme = generateTemplate(config); - // Should have words from both presets - expect(theme.schema.matchWords?.INFO).toBeDefined(); // from log-levels - expect(theme.schema.matchWords?.GET).toBeDefined(); // from http-api + expect(theme.schema.matchWords?.INFO).toBeDefined(); + expect(theme.schema.matchWords?.GET).toBeDefined(); - // Should have patterns from both presets const timestampPattern = theme.schema.matchPatterns?.find( (p) => p.name === "timestamp-iso", ); diff --git a/tests/unit/themes/utils.test.ts b/tests/unit/themes/utils.test.ts index f08de5c..d69c8ff 100644 --- a/tests/unit/themes/utils.test.ts +++ b/tests/unit/themes/utils.test.ts @@ -38,8 +38,8 @@ describe("themes/utils", () => { }); test("correctly identifies medium luminance colors", () => { - expect(isDarkColor("#808080")).toBe(false); // Medium gray - expect(isDarkColor("#404040")).toBe(true); // Dark medium gray + expect(isDarkColor("#808080")).toBe(false); + expect(isDarkColor("#404040")).toBe(true); }); }); @@ -102,7 +102,6 @@ describe("themes/utils", () => { const colorsAA = getAccessibleTextColors("#000000", "AA"); const colorsAAA = getAccessibleTextColors("#000000", "AAA"); - // AAA should provide higher contrast (lighter colors for dark background) expect(colorsAAA.text).not.toBe(colorsAA.text); }); @@ -110,7 +109,6 @@ describe("themes/utils", () => { const colorsAA = getAccessibleTextColors("#FFFFFF", "AA"); const colorsAAA = getAccessibleTextColors("#FFFFFF", "AAA"); - // AAA should provide higher contrast (darker colors for light background) expect(colorsAAA.text).not.toBe(colorsAA.text); }); }); diff --git a/tests/unit/tokenizer/index.test.ts b/tests/unit/tokenizer/index.test.ts index 46bd241..5b43554 100644 --- a/tests/unit/tokenizer/index.test.ts +++ b/tests/unit/tokenizer/index.test.ts @@ -36,7 +36,6 @@ describe("Tokenizer", () => { expect(tokens).toBeInstanceOf(Array); expect(tokens.length).toBeGreaterThan(0); - // The entire string should be tokenized const totalContent = tokens.map((t) => t.content).join(""); expect(totalContent).toBe(line); }); @@ -45,15 +44,11 @@ describe("Tokenizer", () => { const line = "2023-01-01T12:00:00Z ERROR: Something went wrong"; const tokens = tokenize(line); - // The default tokenizer might not identify timestamps and log levels - // Let's just check that the tokens contain the expected content const content = tokens.map((t) => t.content).join(""); expect(content).toBe(line); - // Check that we have multiple tokens expect(tokens.length).toBeGreaterThan(1); - // Check that at least one token contains "ERROR" const hasError = tokens.some((t) => t.content.includes("ERROR")); expect(hasError).toBe(true); }); @@ -61,12 +56,10 @@ describe("Tokenizer", () => { test("handles whitespace according to theme preferences", () => { const line = "test with spaces"; - // Default (preserve whitespace) const tokensPreserve = tokenize(line); const contentPreserve = tokensPreserve.map((t) => t.content).join(""); expect(contentPreserve).toBe(line); - // Test with a theme that has whitespace trimming enabled const themeTrim: Theme = { name: "Trim Whitespace", schema: { @@ -74,7 +67,6 @@ describe("Tokenizer", () => { }, }; - // Just verify that tokenization works with this theme const tokensTrim = tokenize(line, themeTrim); expect(tokensTrim).toBeInstanceOf(Array); expect(tokensTrim.length).toBeGreaterThan(0); @@ -83,12 +75,10 @@ describe("Tokenizer", () => { test("handles newlines according to theme preferences", () => { const line = "line1\nline2"; - // Default (preserve newlines) const tokensPreserve = tokenize(line); const hasNewline = tokensPreserve.some((t) => t.content === "\n"); expect(hasNewline).toBe(true); - // Test with a theme that has newline trimming enabled const themeTrim: Theme = { name: "Trim Newlines", schema: { @@ -96,14 +86,12 @@ describe("Tokenizer", () => { }, }; - // Just verify that tokenization works with this theme const tokensTrim = tokenize(line, themeTrim); expect(tokensTrim).toBeInstanceOf(Array); expect(tokensTrim.length).toBeGreaterThan(0); }); test("applies theme-specific word matching", () => { - // Create a theme with a word matcher const theme: Theme = { name: "Word Theme", schema: { @@ -113,7 +101,6 @@ describe("Tokenizer", () => { }, }; - // Create a token that should match the word const tokens: TokenList = [ { content: "test", @@ -124,15 +111,12 @@ describe("Tokenizer", () => { }, ]; - // Apply the theme to the tokens const styledTokens = applyTheme(tokens, theme); - // Check if the token has the style we expect expect(styledTokens[0].metadata?.style?.color).toBe("green"); }); test("applies theme-specific pattern matching", () => { - // Create tokens manually const tokens: TokenList = [ { content: "123", @@ -144,7 +128,6 @@ describe("Tokenizer", () => { }, ]; - // Create a theme const theme: Theme = { name: "Simple Pattern Theme", schema: { @@ -158,18 +141,14 @@ describe("Tokenizer", () => { }, }; - // Apply the theme to the tokens const styledTokens = applyTheme(tokens, theme); - // Check if the token has the style we expect expect(styledTokens[0].metadata?.style?.color).toBe("blue"); }); test("handles invalid regex patterns gracefully", () => { - // Save original console.warn const originalWarn = console.warn; - // Temporarily silence console.warn console.warn = () => {}; try { @@ -372,4 +351,229 @@ describe("Tokenizer", () => { expect(styledTokens[0].metadata?.style).toEqual({ color: "white" }); }); }); + + describe("whitespace handling", () => { + test("handles tabs in text", () => { + const line = "text\twith\ttabs"; + const tokens = tokenize(line); + const content = tokens.map((t) => t.content).join(""); + expect(content).toBe(line); + }); + + test("handles carriage returns", () => { + const line = "line1\r\nline2\rline3"; + const tokens = tokenize(line); + const content = tokens.map((t) => t.content).join(""); + expect(content).toBe(line); + }); + + test("handles multiple spaces", () => { + const line = "text with multiple spaces"; + const tokens = tokenize(line); + const content = tokens.map((t) => t.content).join(""); + expect(content).toBe(line); + }); + + test("handles tabs with trim whitespace", () => { + const theme: Theme = { + name: "Trim Tabs", + schema: { + whiteSpace: "trim", + }, + }; + const line = "text\twith\ttabs"; + const tokens = tokenize(line, theme); + expect(tokens).toBeInstanceOf(Array); + }); + + test("handles mixed whitespace characters", () => { + const line = "text \t\r\n with mixed whitespace"; + const tokens = tokenize(line); + const content = tokens.map((t) => t.content).join(""); + expect(content).toBe(line); + }); + + test("trims multiple spaces when whiteSpace is trim", () => { + const theme: Theme = { + name: "Trim Spaces", + schema: { + whiteSpace: "trim", + }, + }; + const line = "text with multiple spaces"; + const tokens = tokenize(line, theme); + + expect(tokens).toBeInstanceOf(Array); + expect(tokens.length).toBeGreaterThan(0); + + const hasSpaceTokens = tokens.some( + (t) => + t.metadata?.matchType === "spaces" || + t.metadata?.matchType === "space", + ); + expect(hasSpaceTokens).toBe(true); + }); + + test("trims single space when whiteSpace is trim", () => { + const theme: Theme = { + name: "Trim Space", + schema: { + whiteSpace: "trim", + }, + }; + const line = "text with space"; + const tokens = tokenize(line, theme); + + const spaceTokens = tokens.filter( + (t) => + t.metadata?.matchType === "space" || + t.metadata?.matchType === "spaces", + ); + expect(spaceTokens.length).toBeGreaterThan(0); + }); + + test("handles newlines when newLine is trim", () => { + const theme: Theme = { + name: "Trim Newlines", + schema: { + newLine: "trim", + }, + }; + const line = "line1\nline2\nline3"; + const tokens = tokenize(line, theme); + + expect(tokens).toBeInstanceOf(Array); + expect(tokens.length).toBeGreaterThan(0); + }); + + test("handles carriage returns when newLine is trim", () => { + const theme: Theme = { + name: "Trim CR", + schema: { + newLine: "trim", + }, + }; + const line = "line1\r\nline2"; + const tokens = tokenize(line, theme); + + expect(tokens).toBeInstanceOf(Array); + expect(tokens.length).toBeGreaterThan(0); + }); + }); + + describe("pattern matching with identifiers", () => { + test("handles pattern matching with identifiers", () => { + const theme: Theme = { + name: "Identifier Theme", + schema: { + matchPatterns: [ + { + name: "custom-pattern", + pattern: /\b[A-Z]+\b/g, + options: { color: "yellow" }, + }, + ], + }, + }; + const line = "ERROR WARNING INFO"; + const tokens = tokenize(line, theme); + expect(tokens.length).toBeGreaterThan(0); + }); + + test("validates pattern matches correctly", () => { + const theme: Theme = { + name: "Validation Theme", + schema: { + matchPatterns: [ + { + name: "strict-match", + pattern: /^\d{4}-\d{2}-\d{2}$/, + options: { color: "green" }, + }, + ], + }, + }; + const line = "2024-01-15 is a date"; + const tokens = tokenize(line, theme); + const content = tokens.map((t) => t.content).join(""); + expect(content).toBe(line); + }); + }); + + describe("complex scenarios", () => { + test("handles empty strings", () => { + const line = ""; + const tokens = tokenize(line); + expect(tokens.length).toBeGreaterThanOrEqual(0); + }); + + test("handles strings with only whitespace", () => { + const line = " \t \n "; + const tokens = tokenize(line); + const content = tokens.map((t) => t.content).join(""); + expect(content).toBe(line); + }); + + test("handles unicode characters", () => { + const line = "Hello 世界 🌍"; + const tokens = tokenize(line); + const content = tokens.map((t) => t.content).join(""); + expect(content).toBe(line); + }); + + test("handles very long lines", () => { + const line = "a".repeat(10000); + const tokens = tokenize(line); + const content = tokens.map((t) => t.content).join(""); + expect(content).toBe(line); + }); + + test("handles theme with both word and pattern matches", () => { + const theme: Theme = { + name: "Combined Theme", + schema: { + matchWords: { + ERROR: { color: "red" }, + INFO: { color: "blue" }, + }, + matchPatterns: [ + { + name: "timestamp", + pattern: /\d{2}:\d{2}:\d{2}/g, + options: { color: "gray" }, + }, + ], + }, + }; + const line = "12:00:00 ERROR Something went wrong INFO Check logs"; + const tokens = tokenize(line, theme); + expect(tokens.length).toBeGreaterThan(0); + const content = tokens.map((t) => t.content).join(""); + expect(content).toBe(line); + }); + + test("handles overlapping patterns", () => { + const theme: Theme = { + name: "Overlap Theme", + schema: { + matchPatterns: [ + { + name: "word", + pattern: /\w+/g, + options: { color: "blue" }, + }, + { + name: "number", + pattern: /\d+/g, + options: { color: "green" }, + }, + ], + }, + }; + const line = "test123 word456"; + const tokens = tokenize(line, theme); + const content = tokens.map((t) => t.content).join(""); + expect(content).toBe(line); + }); + }); }); diff --git a/tests/unit/utils/logger.test.ts b/tests/unit/utils/logger.test.ts index 8358198..06e8492 100644 --- a/tests/unit/utils/logger.test.ts +++ b/tests/unit/utils/logger.test.ts @@ -2,9 +2,7 @@ import { describe, expect, test, spyOn, afterEach } from "bun:test"; import { logger } from "../../../src/utils/logger"; describe("logger", () => { - afterEach(() => { - // Restore all spies after each test - }); + afterEach(() => {}); test("info() logs with blue info icon", () => { const spy = spyOn(console, "log"); diff --git a/tests/unit/utils/prompts.test.ts b/tests/unit/utils/prompts.test.ts new file mode 100644 index 0000000..1c67e39 --- /dev/null +++ b/tests/unit/utils/prompts.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; +import { EventEmitter } from "events"; + +class MockReadline extends EventEmitter { + private responses: string[] = []; + private responseIndex = 0; + + setResponses(responses: string[]) { + this.responses = responses; + this.responseIndex = 0; + } + + question(prompt: string, callback: (answer: string) => void) { + const response = this.responses[this.responseIndex++] || ""; + setImmediate(() => callback(response)); + } + + close() {} +} + +const mockRl = new MockReadline(); + +const originalCreateInterface = require("readline").createInterface; + +beforeEach(() => { + require("readline").createInterface = mock(() => mockRl); +}); + +afterEach(() => { + require("readline").createInterface = originalCreateInterface; + mockRl.removeAllListeners(); +}); + +describe("prompts (with mocks)", () => { + describe("input", () => { + it("should return user input", async () => { + mockRl.setResponses(["test value"]); + + const { input } = await import("../../../src/utils/prompts"); + const result = await input({ message: "Enter value" }); + + expect(result).toBe("test value"); + }); + + it("should return default value when empty", async () => { + mockRl.setResponses([""]); + + const { input } = await import("../../../src/utils/prompts"); + const result = await input({ + message: "Enter value", + default: "default", + }); + + expect(result).toBe("default"); + }); + + it("should validate input", async () => { + mockRl.setResponses(["invalid", "valid"]); + + const { input } = await import("../../../src/utils/prompts"); + const validate = (val: string) => val === "valid" || "Must be valid"; + const result = await input({ + message: "Enter value", + validate, + }); + + expect(result).toBe("valid"); + }); + }); + + describe("select", () => { + it("should return selected choice", async () => { + mockRl.setResponses(["2"]); + + const originalLog = console.log; + console.log = mock(() => {}); + + const { select } = await import("../../../src/utils/prompts"); + const result = await select({ + message: "Choose", + choices: ["a", "b", "c"], + }); + + console.log = originalLog; + expect(result).toBe("b"); + }); + + it("should use default when empty input", async () => { + mockRl.setResponses([""]); + + const originalLog = console.log; + console.log = mock(() => {}); + + const { select } = await import("../../../src/utils/prompts"); + const result = await select({ + message: "Choose", + choices: ["a", "b", "c"], + default: "b", + }); + + console.log = originalLog; + expect(result).toBe("b"); + }); + }); + + describe("checkbox", () => { + it("should return selected values", async () => { + mockRl.setResponses(["1,3"]); + + const originalLog = console.log; + console.log = mock(() => {}); + + const { checkbox } = await import("../../../src/utils/prompts"); + const result = await checkbox({ + message: "Select", + choices: [ + { name: "A", value: "a" }, + { name: "B", value: "b" }, + { name: "C", value: "c" }, + ], + }); + + console.log = originalLog; + expect(result).toEqual(["a", "c"]); + }); + }); + + describe("confirm", () => { + it("should return true for yes", async () => { + mockRl.setResponses(["y"]); + + const { confirm } = await import("../../../src/utils/prompts"); + const result = await confirm({ message: "Confirm?" }); + + expect(result).toBe(true); + }); + + it("should return false for no", async () => { + mockRl.setResponses(["n"]); + + const { confirm } = await import("../../../src/utils/prompts"); + const result = await confirm({ message: "Confirm?" }); + + expect(result).toBe(false); + }); + + it("should use default when empty", async () => { + mockRl.setResponses([""]); + + const { confirm } = await import("../../../src/utils/prompts"); + const result = await confirm({ + message: "Confirm?", + default: true, + }); + + expect(result).toBe(true); + }); + }); +}); diff --git a/tests/unit/utils/spinner.test.ts b/tests/unit/utils/spinner.test.ts index 9d0dc67..de50286 100644 --- a/tests/unit/utils/spinner.test.ts +++ b/tests/unit/utils/spinner.test.ts @@ -94,8 +94,8 @@ describe("spinner", () => { s.stop(); const output = mockWrites.join(""); - expect(output).toContain("\x1B[K"); // Clear line - expect(output).toContain("\x1B[?25h"); // Show cursor + expect(output).toContain("\x1B[K"); + expect(output).toContain("\x1B[?25h"); }); test("start should hide cursor and write frames", async () => { @@ -106,7 +106,7 @@ describe("spinner", () => { s.stop(); const output = mockWrites.join(""); - expect(output).toContain("\x1B[?25l"); // Hide cursor + expect(output).toContain("\x1B[?25l"); expect(output).toContain("Test"); });