From 32d92e306a78c776cd64125d7323f519cff8ab20 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 15 Jul 2025 22:43:38 +0200 Subject: [PATCH] feat: add verbosity control to vt command (#356) --- README.md | 150 +++++++++- web/bin/vt | 53 +++- web/src/cli.ts | 16 +- web/src/server/fwd.ts | 70 ++++- web/src/server/server.ts | 2 +- web/src/server/services/mdns-service.ts | 6 +- web/src/server/utils/logger.ts | 191 +++++++++++-- web/src/server/utils/verbosity-parser.ts | 26 ++ web/src/test/unit/cli-verbosity.test.ts | 109 ++++++++ web/src/test/unit/logger-verbosity.test.ts | 304 +++++++++++++++++++++ web/src/test/unit/verbosity-parser.test.ts | 90 ++++++ web/src/test/utils/server-utils.ts | 2 + 12 files changed, 967 insertions(+), 52 deletions(-) create mode 100644 web/src/server/utils/verbosity-parser.ts create mode 100644 web/src/test/unit/cli-verbosity.test.ts create mode 100644 web/src/test/unit/logger-verbosity.test.ts create mode 100644 web/src/test/unit/verbosity-parser.test.ts diff --git a/README.md b/README.md index 3a3de2dc..87061a48 100644 --- a/README.md +++ b/README.md @@ -75,20 +75,21 @@ When you run `vt` from the npm package, it: ```bash # Run any command in the browser vt pnpm run dev +vt npm test +vt python script.py -# Monitor AI agents (with automatic activity tracking) +# Monitor AI agents with automatic activity tracking vt claude --dangerously-skip-permissions +vt --title-mode dynamic claude # See real-time Claude status -# Control terminal titles -vt --title-mode static npm run dev # Shows path and command -vt --title-mode dynamic python app.py # Shows path, command, and activity -vt --title-mode filter vim # Blocks vim from changing title - -# Shell aliases work automatically! -vt claude-danger # Your custom aliases are resolved +# Use your shell aliases +vt gs # Your 'git status' alias works! +vt claude-danger # Custom aliases are resolved # Open an interactive shell -vt --shell +vt --shell # or vt -i + +# For more examples and options, see "The vt Forwarding Command" section below ``` ### Git Repository Scanning on First Session @@ -349,6 +350,12 @@ vt --title-mode static npm run dev # Shows path and command vt --title-mode dynamic python app.py # Shows path, command, and activity vt --title-mode filter vim # Blocks vim from changing title +# Control output verbosity +vt -q npm test # Quiet mode - no console output +vt -v npm run dev # Verbose mode - show more information +vt -vv cargo build # Extra verbose - all except debug +vt -vvv python app.py # Debug mode - show everything + # Shell aliases work automatically! vt claude-danger # Your custom alias for claude --dangerously-skip-permissions @@ -356,6 +363,106 @@ vt claude-danger # Your custom alias for claude --dangerously-skip-permissions vt title "My Project - Testing" ``` +### The `vt` Forwarding Command + +The `vt` command is VibeTunnel's terminal forwarding wrapper that allows you to run any command while making its output visible in the browser. Under the hood, `vt` is a convenient shortcut for `vibetunnel fwd` - it's a bash script that calls the full command with proper path resolution and additional features like shell alias support. The `vt` wrapper acts as a transparent proxy between your terminal and the command, forwarding all input and output through VibeTunnel's infrastructure. + +#### Command Syntax + +```bash +vt [options] [args...] +``` + +#### Options + +**Terminal Title Control:** +- `--title-mode ` - Control how terminal titles are managed: + - `none` - No title management, apps control their own titles (default) + - `filter` - Block all title changes from applications + - `static` - Show working directory and command in title + - `dynamic` - Show directory, command, and live activity status (auto-enabled for Claude) + +**Verbosity Control:** +- `-q, --quiet` - Quiet mode, no console output (logs to file only) +- `-v, --verbose` - Verbose mode, show errors, warnings, and info messages +- `-vv` - Extra verbose, show all messages except debug +- `-vvv` - Debug mode, show all messages including debug + +**Other Options:** +- `--shell, -i` - Launch your current shell interactively +- `--no-shell-wrap, -S` - Execute command directly without shell interpretation +- `--log-file ` - Override default log file location (defaults to `~/.vibetunnel/log.txt`) +- `--help, -h` - Show help message with all options + +#### Verbosity Levels + +VibeTunnel uses a hierarchical logging system where each level includes all messages from more severe levels: + +| Level | Flag | Environment Variable | Shows | +|-------|------|---------------------|-------| +| SILENT | `-q` | `VIBETUNNEL_LOG_LEVEL=silent` | No console output (file logging only) | +| ERROR | (default) | `VIBETUNNEL_LOG_LEVEL=error` | Errors only | +| WARN | - | `VIBETUNNEL_LOG_LEVEL=warn` | Errors and warnings | +| INFO | `-v` | `VIBETUNNEL_LOG_LEVEL=info` | Errors, warnings, and informational messages | +| VERBOSE | `-vv` | `VIBETUNNEL_LOG_LEVEL=verbose` | All messages except debug | +| DEBUG | `-vvv` | `VIBETUNNEL_LOG_LEVEL=debug` | Everything including debug traces | + +**Note:** All logs are always written to `~/.vibetunnel/log.txt` regardless of verbosity settings. The verbosity only controls terminal output. + +#### Examples + +```bash +# Basic command forwarding +vt ls -la # List files with VibeTunnel monitoring +vt npm run dev # Run development server +vt python script.py # Execute Python script + +# With verbosity control +vt -q npm test # Run tests silently +vt -v npm install # See detailed installation progress +vt -vvv python debug.py # Full debug output +vt --log-file debug.log npm run dev # Write logs to custom file + +# Terminal title management +vt --title-mode static npm run dev # Fixed title showing command +vt --title-mode dynamic claude # Live activity updates +vt --title-mode filter vim # Prevent vim from changing title + +# Shell handling +vt --shell # Open interactive shell +vt -S /usr/bin/python # Run python directly without shell +``` + +#### How It Works + +1. **Command Resolution**: The `vt` wrapper first checks if your command is an alias, shell function, or binary +2. **Session Creation**: It creates a new VibeTunnel session with a unique ID +3. **PTY Allocation**: A pseudo-terminal is allocated to preserve terminal features (colors, cursor control, etc.) +4. **I/O Forwarding**: All input/output is forwarded between your terminal and the browser in real-time +5. **Process Management**: The wrapper monitors the process and handles signals, exit codes, and cleanup + +#### Environment Variables + +- `VIBETUNNEL_LOG_LEVEL` - Set default verbosity level (silent, error, warn, info, verbose, debug) +- `VIBETUNNEL_TITLE_MODE` - Set default title mode (none, filter, static, dynamic) +- `VIBETUNNEL_DEBUG` - Legacy debug flag, equivalent to `VIBETUNNEL_LOG_LEVEL=debug` +- `VIBETUNNEL_CLAUDE_DYNAMIC_TITLE` - Force dynamic title mode for Claude commands + +#### Special Features + +**Automatic Claude Detection**: When running Claude AI, `vt` automatically enables dynamic title mode to show real-time activity status (thinking, writing, idle). + +**Shell Alias Support**: Your shell aliases and functions work transparently through `vt`: +```bash +alias gs='git status' +vt gs # Works as expected +``` + +**Session Title Updates**: Inside a VibeTunnel session, use `vt title` to update the session name: +```bash +vt title "Building Production Release" +``` + ### Mac App Interoperability The npm package is designed to work seamlessly alongside the Mac app: @@ -622,6 +729,31 @@ VIBETUNNEL_DEBUG=1 vt your-command Debug logs are written to `~/.vibetunnel/log.txt`. +### Verbosity Control + +Control the amount of output from VibeTunnel commands: + +```bash +# Command-line flags +vt -q npm test # Quiet mode - no console output +vt npm test # Default - errors only +vt -v npm run dev # Verbose - errors, warnings, and info +vt -vv cargo build # Extra verbose - all except debug +vt -vvv python script.py # Debug mode - everything + +# Environment variable +export VIBETUNNEL_LOG_LEVEL=error # Default +export VIBETUNNEL_LOG_LEVEL=warn # Show errors and warnings +export VIBETUNNEL_LOG_LEVEL=info # Show errors, warnings, and info +export VIBETUNNEL_LOG_LEVEL=verbose # All except debug +export VIBETUNNEL_LOG_LEVEL=debug # Everything + +# Or use inline +VIBETUNNEL_LOG_LEVEL=silent vt npm test +``` + +**Note**: All logs are always written to `~/.vibetunnel/log.txt` regardless of verbosity level. The verbosity settings only control what's displayed in the terminal. + ## Documentation - [Keyboard Shortcuts](docs/keyboard-shortcuts.md) - Complete keyboard shortcut reference diff --git a/web/bin/vt b/web/bin/vt index c1c40faf..054177e2 100755 --- a/web/bin/vt +++ b/web/bin/vt @@ -141,6 +141,9 @@ USAGE: vt title # Inside a VibeTunnel session only vt --help +QUICK VERBOSITY: + -q (quiet), -v (verbose), -vv (extra), -vvv (debug) + DESCRIPTION: This wrapper script allows VibeTunnel to see the output of commands by forwarding TTY data through the vibetunnel utility. When you run commands @@ -160,12 +163,18 @@ EXAMPLES: vt -i # Launch current shell (short form) vt -S ls -la # List files without shell alias resolution vt title "My Project" # Update session title (inside session only) + vt -q npm test # Run with minimal output (errors only) + vt -vv npm run dev # Run with verbose output OPTIONS: --shell, -i Launch current shell (equivalent to vt $SHELL) --no-shell-wrap, -S Execute command directly without shell wrapper --title-mode Terminal title mode (none, filter, static, dynamic) Default: none (dynamic for claude) + --quiet, -q Quiet mode - only show errors + --verbose, -v Verbose mode - show more information + -vv Extra verbose - show all except debug + -vvv Debug mode - show all messages --help, -h Show this help message and exit TITLE MODES: @@ -174,6 +183,20 @@ TITLE MODES: static Show working directory and command in title dynamic Show directory, command, and live activity status (default for web UI) +VERBOSITY: + By default, only errors are shown. Use verbosity flags to control output: + -q/--quiet Suppress all output except critical errors + -v/--verbose Show errors, warnings, and informational messages + -vv Show everything except debug messages + -vvv Show all messages including debug + + You can also set VIBETUNNEL_LOG_LEVEL environment variable: + export VIBETUNNEL_LOG_LEVEL=error # Default + export VIBETUNNEL_LOG_LEVEL=warn # Show errors and warnings + export VIBETUNNEL_LOG_LEVEL=info # Show errors, warnings, and info + export VIBETUNNEL_LOG_LEVEL=verbose # All except debug + export VIBETUNNEL_LOG_LEVEL=debug # Everything + NOTE: This script automatically detects and uses the best available VibeTunnel installation: - Mac app bundle (preferred on macOS) @@ -236,15 +259,15 @@ resolve_command() { case "$shell_name" in zsh) # For zsh, we need interactive mode to get aliases - exec "$VIBETUNNEL_BIN" fwd ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -i -c "$(printf '%q ' "$cmd" "$@")" + exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -i -c "$(printf '%q ' "$cmd" "$@")" ;; bash) # For bash, expand aliases in non-interactive mode - exec "$VIBETUNNEL_BIN" fwd ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -c "shopt -s expand_aliases; source ~/.bashrc 2>/dev/null || source ~/.bash_profile 2>/dev/null || true; $(printf '%q ' "$cmd" "$@")" + exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -c "shopt -s expand_aliases; source ~/.bashrc 2>/dev/null || source ~/.bash_profile 2>/dev/null || true; $(printf '%q ' "$cmd" "$@")" ;; *) # Generic shell handling - exec "$VIBETUNNEL_BIN" fwd ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -c "$(printf '%q ' "$cmd" "$@")" + exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -c "$(printf '%q ' "$cmd" "$@")" ;; esac } @@ -262,11 +285,27 @@ if [[ "$1" == "title" ]]; then exit 1 fi +# Handle verbosity flags +VERBOSITY_ARGS="" +if [[ "$1" == "--quiet" || "$1" == "-q" ]]; then + VERBOSITY_ARGS="--verbosity silent" + shift +elif [[ "$1" == "--verbose" || "$1" == "-v" ]]; then + VERBOSITY_ARGS="--verbosity info" + shift +elif [[ "$1" == "-vv" ]]; then + VERBOSITY_ARGS="--verbosity verbose" + shift +elif [[ "$1" == "-vvv" ]]; then + VERBOSITY_ARGS="--verbosity debug" + shift +fi + # Handle --shell or -i option (launch current shell) if [[ "$1" == "--shell" || "$1" == "-i" ]]; then shift # Execute current shell through vibetunnel - exec "$0" "${SHELL:-/bin/bash}" "$@" + exec "$0" ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} "${SHELL:-/bin/bash}" "$@" fi # Handle --no-shell-wrap or -S option @@ -287,12 +326,12 @@ fi if [ $# -gt 0 ] && [[ "$1" != -* ]]; then if [[ "$NO_SHELL_WRAP" == "true" ]]; then # Execute directly without shell wrapper - exec "$VIBETUNNEL_BIN" fwd ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@" + exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@" else # Check if the first argument is a real binary if which "$1" >/dev/null 2>&1; then # It's a real binary, execute directly - exec "$VIBETUNNEL_BIN" fwd ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@" + exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@" else # Not a real binary, try alias resolution resolve_command "$@" @@ -300,5 +339,5 @@ if [ $# -gt 0 ] && [[ "$1" != -* ]]; then fi else # Run with fwd command (original behavior for options) - exec "$VIBETUNNEL_BIN" fwd ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@" + exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@" fi \ No newline at end of file diff --git a/web/src/cli.ts b/web/src/cli.ts index 4b78c195..8a26d85a 100644 --- a/web/src/cli.ts +++ b/web/src/cli.ts @@ -8,13 +8,18 @@ suppressXtermErrors(); import { startVibeTunnelForward } from './server/fwd.js'; import { startVibeTunnelServer } from './server/server.js'; -import { closeLogger, createLogger, initLogger } from './server/utils/logger.js'; +import { closeLogger, createLogger, initLogger, VerbosityLevel } from './server/utils/logger.js'; +import { parseVerbosityFromEnv } from './server/utils/verbosity-parser.js'; import { VERSION } from './server/version.js'; // Initialize logger before anything else -// Check VIBETUNNEL_DEBUG environment variable for debug mode +// Parse verbosity from environment variables +const verbosityLevel = parseVerbosityFromEnv(); + +// Check for legacy debug mode (for backward compatibility with initLogger) const debugMode = process.env.VIBETUNNEL_DEBUG === '1' || process.env.VIBETUNNEL_DEBUG === 'true'; -initLogger(debugMode); + +initLogger(debugMode, verbosityLevel); const logger = createLogger('cli'); // Source maps are only included if built with --sourcemap flag @@ -61,7 +66,10 @@ if (!module.parent && (require.main === module || require.main === undefined)) { process.exit(1); }); } else { - logger.log('Starting VibeTunnel server...'); + // Show startup message at INFO level or when debug is enabled + if (verbosityLevel !== undefined && verbosityLevel >= VerbosityLevel.INFO) { + logger.log('Starting VibeTunnel server...'); + } startVibeTunnelServer(); } } diff --git a/web/src/server/fwd.ts b/web/src/server/fwd.ts index cc987686..813b685e 100755 --- a/web/src/server/fwd.ts +++ b/web/src/server/fwd.ts @@ -21,9 +21,17 @@ import { SessionManager } from './pty/session-manager.js'; import { VibeTunnelSocketClient } from './pty/socket-client.js'; import { ActivityDetector } from './utils/activity-detector.js'; import { checkAndPatchClaude } from './utils/claude-patcher.js'; -import { closeLogger, createLogger } from './utils/logger.js'; +import { + closeLogger, + createLogger, + parseVerbosityLevel, + setLogFilePath, + setVerbosityLevel, + VerbosityLevel, +} from './utils/logger.js'; import { generateSessionName } from './utils/session-naming.js'; import { generateTitleSequence } from './utils/terminal-title.js'; +import { parseVerbosityFromEnv } from './utils/verbosity-parser.js'; import { BUILD_DATE, GIT_COMMIT, VERSION } from './version.js'; const logger = createLogger('fwd'); @@ -33,7 +41,7 @@ function showUsage() { console.log(''); console.log('Usage:'); console.log( - ' pnpm exec tsx src/fwd.ts [--session-id ] [--title-mode ] [args...]' + ' pnpm exec tsx src/fwd.ts [--session-id ] [--title-mode ] [--verbosity ] [args...]' ); console.log(''); console.log('Options:'); @@ -41,6 +49,12 @@ function showUsage() { console.log(' --title-mode Terminal title mode: none, filter, static, dynamic'); console.log(' (defaults to none for most commands, dynamic for claude)'); console.log(' --update-title Update session title and exit (requires --session-id)'); + console.log( + ' --verbosity <level> Set logging verbosity: silent, error, warn, info, verbose, debug' + ); + console.log(' (defaults to error)'); + console.log(' --log-file <path> Override default log file location'); + console.log(' (defaults to ~/.vibetunnel/log.txt)'); console.log(''); console.log('Title Modes:'); console.log(' none - No title management (default)'); @@ -48,9 +62,23 @@ function showUsage() { console.log(' static - Show working directory and command'); console.log(' dynamic - Show directory, command, and activity (auto-selected for claude)'); console.log(''); + console.log('Verbosity Levels:'); + console.log(` ${chalk.gray('silent')} - No output except critical errors`); + console.log(` ${chalk.red('error')} - Only errors ${chalk.gray('(default)')}`); + console.log(` ${chalk.yellow('warn')} - Errors and warnings`); + console.log(` ${chalk.green('info')} - Errors, warnings, and informational messages`); + console.log(` ${chalk.blue('verbose')} - All messages except debug`); + console.log(` ${chalk.magenta('debug')} - All messages including debug`); + console.log(''); + console.log( + `Quick verbosity: ${chalk.cyan('-q (quiet), -v (verbose), -vv (extra), -vvv (debug)')}` + ); + console.log(''); console.log('Environment Variables:'); console.log(' VIBETUNNEL_TITLE_MODE=<mode> Set default title mode'); console.log(' VIBETUNNEL_CLAUDE_DYNAMIC_TITLE=1 Force dynamic title for Claude'); + console.log(' VIBETUNNEL_LOG_LEVEL=<level> Set default verbosity level'); + console.log(' VIBETUNNEL_DEBUG=1 Enable debug mode (legacy)'); console.log(''); console.log('Examples:'); console.log(' pnpm exec tsx src/fwd.ts claude --resume'); @@ -58,16 +86,19 @@ function showUsage() { console.log(' pnpm exec tsx src/fwd.ts --title-mode filter vim'); console.log(' pnpm exec tsx src/fwd.ts --session-id abc123 claude'); console.log(' pnpm exec tsx src/fwd.ts --update-title "New Title" --session-id abc123'); + console.log(' pnpm exec tsx src/fwd.ts --verbosity silent npm test'); console.log(''); console.log('The command will be spawned in the current working directory'); console.log('and managed through the VibeTunnel PTY infrastructure.'); } export async function startVibeTunnelForward(args: string[]) { - // Log startup with version (logger already initialized in cli.ts) - if (process.env.VIBETUNNEL_DEBUG === '1' || process.env.VIBETUNNEL_DEBUG === 'true') { + // Parse verbosity from environment variables + let verbosityLevel = parseVerbosityFromEnv(); + + // Set debug mode on logger for backward compatibility + if (verbosityLevel === VerbosityLevel.DEBUG) { logger.setDebugMode(true); - logger.warn('Debug mode enabled'); } // Parse command line arguments @@ -84,6 +115,7 @@ export async function startVibeTunnelForward(args: string[]) { let sessionId: string | undefined; let titleMode: TitleMode = TitleMode.NONE; let updateTitle: string | undefined; + let logFilePath: string | undefined; let remainingArgs = args; // Check environment variables for title mode @@ -123,6 +155,20 @@ export async function startVibeTunnelForward(args: string[]) { process.exit(1); } remainingArgs = remainingArgs.slice(2); + } else if (remainingArgs[0] === '--verbosity' && remainingArgs.length > 1) { + const parsedLevel = parseVerbosityLevel(remainingArgs[1]); + if (parsedLevel !== undefined) { + verbosityLevel = parsedLevel; + } else { + logger.error(`Invalid verbosity level: ${remainingArgs[1]}`); + logger.error('Valid levels: silent, error, warn, info, verbose, debug'); + closeLogger(); + process.exit(1); + } + remainingArgs = remainingArgs.slice(2); + } else if (remainingArgs[0] === '--log-file' && remainingArgs.length > 1) { + logFilePath = remainingArgs[1]; + remainingArgs = remainingArgs.slice(2); } else { // Not a flag, must be the start of the command break; @@ -135,6 +181,20 @@ export async function startVibeTunnelForward(args: string[]) { remainingArgs = remainingArgs.slice(1); } + // Apply log file path if set + if (logFilePath !== undefined) { + setLogFilePath(logFilePath); + logger.debug(`Log file path set to: ${logFilePath}`); + } + + // Apply verbosity level if set + if (verbosityLevel !== undefined) { + setVerbosityLevel(verbosityLevel); + if (verbosityLevel >= VerbosityLevel.INFO) { + logger.log(`Verbosity level set to: ${VerbosityLevel[verbosityLevel].toLowerCase()}`); + } + } + // Handle special case: --update-title mode if (updateTitle !== undefined) { if (!sessionId) { diff --git a/web/src/server/server.ts b/web/src/server/server.ts index c8aaee2e..725afe35 100644 --- a/web/src/server/server.ts +++ b/web/src/server/server.ts @@ -1029,7 +1029,7 @@ export async function createApp(): Promise<AppInstance> { // Start mDNS advertisement if enabled if (config.enableMDNS) { mdnsService.startAdvertising(actualPort).catch((err) => { - logger.error('Failed to start mDNS advertisement:', err); + logger.warn('Failed to start mDNS advertisement:', err); }); } else { logger.debug('mDNS advertisement disabled'); diff --git a/web/src/server/services/mdns-service.ts b/web/src/server/services/mdns-service.ts index 34810bbc..5bd51465 100644 --- a/web/src/server/services/mdns-service.ts +++ b/web/src/server/services/mdns-service.ts @@ -52,11 +52,11 @@ export class MDNSService { }); this.service.on('error', (...args: unknown[]) => { - log.error('mDNS service error:', args[0]); + log.warn('mDNS service error:', args[0]); }); } } catch (error) { - log.error('Failed to start mDNS advertisement:', error); + log.warn('Failed to start mDNS advertisement:', error); throw error; } } @@ -92,7 +92,7 @@ export class MDNSService { this.isAdvertising = false; log.log('Stopped mDNS advertisement'); } catch (error) { - log.error('Error stopping mDNS advertisement:', error); + log.warn('Error stopping mDNS advertisement:', error); } } diff --git a/web/src/server/utils/logger.ts b/web/src/server/utils/logger.ts index b49b0339..f041b1df 100644 --- a/web/src/server/utils/logger.ts +++ b/web/src/server/utils/logger.ts @@ -5,11 +5,78 @@ import * as path from 'path'; // Log file path const LOG_DIR = path.join(os.homedir(), '.vibetunnel'); -const LOG_FILE = path.join(LOG_DIR, 'log.txt'); +let LOG_FILE = path.join(LOG_DIR, 'log.txt'); -// Debug mode flag +/** + * Set custom log file path + */ +export function setLogFilePath(filePath: string): void { + // Close existing file handle if open + if (logFileHandle) { + logFileHandle.end(); + logFileHandle = null; + } + + LOG_FILE = filePath; + + // Ensure directory exists + const dir = path.dirname(LOG_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Re-open log file at new location + try { + logFileHandle = fs.createWriteStream(LOG_FILE, { flags: 'a' }); + } catch (error) { + console.error('Failed to open log file at new location:', error); + } +} + +// Verbosity levels +export enum VerbosityLevel { + SILENT = 0, // No console output (logs to file only) + ERROR = 1, // Errors only (default) + WARN = 2, // Errors and warnings + INFO = 3, // Errors, warnings, and info + VERBOSE = 4, // All except debug + DEBUG = 5, // Everything +} + +/** + * Type-safe mapping of string names to verbosity levels + */ +export const VERBOSITY_MAP: Record<string, VerbosityLevel> = { + silent: VerbosityLevel.SILENT, + error: VerbosityLevel.ERROR, + warn: VerbosityLevel.WARN, + info: VerbosityLevel.INFO, + verbose: VerbosityLevel.VERBOSE, + debug: VerbosityLevel.DEBUG, +} as const; + +// Current verbosity level +let verbosityLevel: VerbosityLevel = VerbosityLevel.ERROR; + +// Debug mode flag (kept for backward compatibility) +// biome-ignore lint/correctness/noUnusedVariables: Used for backward compatibility let debugMode = false; +/** + * Type guard to check if a string is a valid VerbosityLevel key + */ +export function isVerbosityLevel(value: string): value is keyof typeof VERBOSITY_MAP { + return value.toLowerCase() in VERBOSITY_MAP; +} + +/** + * Parse a string to VerbosityLevel, returns undefined if invalid + */ +export function parseVerbosityLevel(value: string): VerbosityLevel | undefined { + const normalized = value.toLowerCase(); + return VERBOSITY_MAP[normalized]; +} + // File handle for log file let logFileHandle: fs.WriteStream | null = null; @@ -20,9 +87,17 @@ const ANSI_PATTERN = /\x1b\[[0-9;]*m/g; /** * Initialize the logger - creates log directory and file */ -export function initLogger(debug: boolean = false): void { +export function initLogger(debug: boolean = false, verbosity?: VerbosityLevel): void { debugMode = debug; + // Set verbosity level + if (verbosity !== undefined) { + verbosityLevel = verbosity; + } else if (debug) { + // If debug mode is enabled, set verbosity to DEBUG + verbosityLevel = VerbosityLevel.DEBUG; + } + // If already initialized, just update debug mode and return if (logFileHandle) { return; @@ -39,9 +114,9 @@ export function initLogger(debug: boolean = false): void { if (fs.existsSync(LOG_FILE)) { fs.unlinkSync(LOG_FILE); } - } catch (unlinkError) { + } catch { // Ignore unlink errors - file might not exist or be locked - console.debug('Could not delete old log file:', unlinkError); + // Don't log here as logger isn't fully initialized yet } // Create new log file write stream @@ -132,16 +207,72 @@ function writeToFile(message: string): void { */ export function setDebugMode(enabled: boolean): void { debugMode = enabled; + // If enabling debug mode, also set verbosity to DEBUG + if (enabled) { + verbosityLevel = VerbosityLevel.DEBUG; + } +} + +/** + * Set verbosity level + */ +export function setVerbosityLevel(level: VerbosityLevel): void { + verbosityLevel = level; + // Update debug mode flag for backward compatibility + debugMode = level >= VerbosityLevel.DEBUG; +} + +/** + * Get current verbosity level + */ +export function getVerbosityLevel(): VerbosityLevel { + return verbosityLevel; +} + +/** + * Check if debug logging is enabled + */ +export function isDebugEnabled(): boolean { + return verbosityLevel >= VerbosityLevel.DEBUG; +} + +/** + * Check if verbose logging is enabled + */ +export function isVerbose(): boolean { + return verbosityLevel >= VerbosityLevel.VERBOSE; +} + +/** + * Check if a log level should be output based on current verbosity + */ +function shouldLog(level: string): boolean { + switch (level) { + case 'ERROR': + return verbosityLevel >= VerbosityLevel.ERROR; + case 'WARN': + return verbosityLevel >= VerbosityLevel.WARN; + case 'LOG': + return verbosityLevel >= VerbosityLevel.INFO; + case 'DEBUG': + return verbosityLevel >= VerbosityLevel.DEBUG; + default: + return true; + } } /** * Log from a specific module (used by client-side API) */ export function logFromModule(level: string, module: string, args: unknown[]): void { - if (level === 'DEBUG' && !debugMode) return; - const { console: consoleMsg, file: fileMsg } = formatMessage(level, module, args); + // Always write to file + writeToFile(fileMsg); + + // Check if we should output to console based on verbosity + if (!shouldLog(level)) return; + // Log to console switch (level) { case 'ERROR': @@ -153,9 +284,6 @@ export function logFromModule(level: string, module: string, args: unknown[]): v default: console.log(consoleMsg); } - - // Log to file - writeToFile(fileMsg); } /** @@ -167,10 +295,22 @@ export function createLogger(moduleName: string) { const prefixedModuleName = moduleName.startsWith('[') ? moduleName : `[SRV] ${moduleName}`; return { + /** + * @deprecated Use info() instead for clarity + */ log: (...args: unknown[]) => { const { console: consoleMsg, file: fileMsg } = formatMessage('LOG', prefixedModuleName, args); - console.log(consoleMsg); - writeToFile(fileMsg); + writeToFile(fileMsg); // Always write to file + if (shouldLog('LOG')) { + console.log(consoleMsg); + } + }, + info: (...args: unknown[]) => { + const { console: consoleMsg, file: fileMsg } = formatMessage('LOG', prefixedModuleName, args); + writeToFile(fileMsg); // Always write to file + if (shouldLog('LOG')) { + console.log(consoleMsg); + } }, warn: (...args: unknown[]) => { const { console: consoleMsg, file: fileMsg } = formatMessage( @@ -178,8 +318,10 @@ export function createLogger(moduleName: string) { prefixedModuleName, args ); - console.warn(consoleMsg); - writeToFile(fileMsg); + writeToFile(fileMsg); // Always write to file + if (shouldLog('WARN')) { + console.warn(consoleMsg); + } }, error: (...args: unknown[]) => { const { console: consoleMsg, file: fileMsg } = formatMessage( @@ -187,20 +329,23 @@ export function createLogger(moduleName: string) { prefixedModuleName, args ); - console.error(consoleMsg); - writeToFile(fileMsg); + writeToFile(fileMsg); // Always write to file + if (shouldLog('ERROR')) { + console.error(consoleMsg); + } }, debug: (...args: unknown[]) => { - if (debugMode) { - const { console: consoleMsg, file: fileMsg } = formatMessage( - 'DEBUG', - prefixedModuleName, - args - ); + const { console: consoleMsg, file: fileMsg } = formatMessage( + 'DEBUG', + prefixedModuleName, + args + ); + writeToFile(fileMsg); // Always write to file + if (shouldLog('DEBUG')) { console.log(consoleMsg); - writeToFile(fileMsg); } }, setDebugMode: (enabled: boolean) => setDebugMode(enabled), + setVerbosity: (level: VerbosityLevel) => setVerbosityLevel(level), }; } diff --git a/web/src/server/utils/verbosity-parser.ts b/web/src/server/utils/verbosity-parser.ts new file mode 100644 index 00000000..46332c81 --- /dev/null +++ b/web/src/server/utils/verbosity-parser.ts @@ -0,0 +1,26 @@ +import { parseVerbosityLevel, VerbosityLevel } from './logger.js'; + +/** + * Parse verbosity level from environment variables + * Checks VIBETUNNEL_LOG_LEVEL first, then falls back to VIBETUNNEL_DEBUG for backward compatibility + * @returns The parsed verbosity level or undefined if not set + */ +export function parseVerbosityFromEnv(): VerbosityLevel | undefined { + // Check VIBETUNNEL_LOG_LEVEL first + if (process.env.VIBETUNNEL_LOG_LEVEL) { + const parsed = parseVerbosityLevel(process.env.VIBETUNNEL_LOG_LEVEL); + if (parsed !== undefined) { + return parsed; + } + // Warn about invalid value + console.warn(`Invalid VIBETUNNEL_LOG_LEVEL: ${process.env.VIBETUNNEL_LOG_LEVEL}`); + console.warn('Valid levels: silent, error, warn, info, verbose, debug'); + } + + // Check legacy VIBETUNNEL_DEBUG for backward compatibility + if (process.env.VIBETUNNEL_DEBUG === '1' || process.env.VIBETUNNEL_DEBUG === 'true') { + return VerbosityLevel.DEBUG; + } + + return undefined; +} diff --git a/web/src/test/unit/cli-verbosity.test.ts b/web/src/test/unit/cli-verbosity.test.ts new file mode 100644 index 00000000..2f5b55de --- /dev/null +++ b/web/src/test/unit/cli-verbosity.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { VerbosityLevel } from '../../server/utils/logger'; +import { parseVerbosityFromEnv } from '../../server/utils/verbosity-parser'; + +describe('CLI Verbosity Environment Variables', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Reset modules to ensure fresh imports + vi.resetModules(); + // Clone the original env + process.env = { ...originalEnv }; + }); + + afterEach(() => { + // Restore original env + process.env = originalEnv; + }); + + async function getVerbosityFromEnv(): Promise<VerbosityLevel | undefined> { + // Use the shared parser + return parseVerbosityFromEnv(); + } + + describe('VIBETUNNEL_LOG_LEVEL', () => { + it('should parse silent level', async () => { + process.env.VIBETUNNEL_LOG_LEVEL = 'silent'; + const level = await getVerbosityFromEnv(); + expect(level).toBe(VerbosityLevel.SILENT); + }); + + it('should parse error level', async () => { + process.env.VIBETUNNEL_LOG_LEVEL = 'error'; + const level = await getVerbosityFromEnv(); + expect(level).toBe(VerbosityLevel.ERROR); + }); + + it('should parse warn level', async () => { + process.env.VIBETUNNEL_LOG_LEVEL = 'warn'; + const level = await getVerbosityFromEnv(); + expect(level).toBe(VerbosityLevel.WARN); + }); + + it('should parse info level', async () => { + process.env.VIBETUNNEL_LOG_LEVEL = 'info'; + const level = await getVerbosityFromEnv(); + expect(level).toBe(VerbosityLevel.INFO); + }); + + it('should parse verbose level', async () => { + process.env.VIBETUNNEL_LOG_LEVEL = 'verbose'; + const level = await getVerbosityFromEnv(); + expect(level).toBe(VerbosityLevel.VERBOSE); + }); + + it('should parse debug level', async () => { + process.env.VIBETUNNEL_LOG_LEVEL = 'debug'; + const level = await getVerbosityFromEnv(); + expect(level).toBe(VerbosityLevel.DEBUG); + }); + + it('should be case-insensitive', async () => { + process.env.VIBETUNNEL_LOG_LEVEL = 'DEBUG'; + const level = await getVerbosityFromEnv(); + expect(level).toBe(VerbosityLevel.DEBUG); + + process.env.VIBETUNNEL_LOG_LEVEL = 'WaRn'; + const level2 = await getVerbosityFromEnv(); + expect(level2).toBe(VerbosityLevel.WARN); + }); + + it('should return undefined for invalid values', async () => { + process.env.VIBETUNNEL_LOG_LEVEL = 'invalid'; + const level = await getVerbosityFromEnv(); + expect(level).toBeUndefined(); + }); + }); + + describe('VIBETUNNEL_DEBUG (backward compatibility)', () => { + it('should enable debug mode with value 1', async () => { + process.env.VIBETUNNEL_DEBUG = '1'; + const level = await getVerbosityFromEnv(); + expect(level).toBe(VerbosityLevel.DEBUG); + }); + + it('should enable debug mode with value true', async () => { + process.env.VIBETUNNEL_DEBUG = 'true'; + const level = await getVerbosityFromEnv(); + expect(level).toBe(VerbosityLevel.DEBUG); + }); + + it('should not enable debug mode with other values', async () => { + process.env.VIBETUNNEL_DEBUG = '0'; + const level = await getVerbosityFromEnv(); + expect(level).toBeUndefined(); + + process.env.VIBETUNNEL_DEBUG = 'false'; + const level2 = await getVerbosityFromEnv(); + expect(level2).toBeUndefined(); + }); + + it('should NOT override valid VIBETUNNEL_LOG_LEVEL when set', async () => { + process.env.VIBETUNNEL_LOG_LEVEL = 'warn'; + process.env.VIBETUNNEL_DEBUG = '1'; + const level = await getVerbosityFromEnv(); + expect(level).toBe(VerbosityLevel.WARN); // VIBETUNNEL_LOG_LEVEL takes precedence + }); + }); +}); diff --git a/web/src/test/unit/logger-verbosity.test.ts b/web/src/test/unit/logger-verbosity.test.ts new file mode 100644 index 00000000..142d6405 --- /dev/null +++ b/web/src/test/unit/logger-verbosity.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createLogger, + getVerbosityLevel, + initLogger, + isDebugEnabled, + isVerbose, + isVerbosityLevel, + parseVerbosityLevel, + setVerbosityLevel, + VERBOSITY_MAP, + VerbosityLevel, +} from '../../server/utils/logger'; + +describe('Logger Verbosity Control', () => { + let consoleLogSpy: ReturnType<typeof vi.spyOn>; + let consoleWarnSpy: ReturnType<typeof vi.spyOn>; + let consoleErrorSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + // Reset to default verbosity + setVerbosityLevel(VerbosityLevel.ERROR); + + // Spy on console methods + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Verbosity Level Management', () => { + it('should default to ERROR level', () => { + expect(getVerbosityLevel()).toBe(VerbosityLevel.ERROR); + }); + + it('should set and get verbosity level', () => { + setVerbosityLevel(VerbosityLevel.INFO); + expect(getVerbosityLevel()).toBe(VerbosityLevel.INFO); + + setVerbosityLevel(VerbosityLevel.DEBUG); + expect(getVerbosityLevel()).toBe(VerbosityLevel.DEBUG); + }); + + it('should initialize with custom verbosity', () => { + initLogger(false, VerbosityLevel.WARN); + expect(getVerbosityLevel()).toBe(VerbosityLevel.WARN); + }); + + it('should set DEBUG verbosity when debug mode is enabled', () => { + initLogger(true); + expect(getVerbosityLevel()).toBe(VerbosityLevel.DEBUG); + }); + }); + + describe('Console Output Control', () => { + const logger = createLogger('test-module'); + + it('should only show errors at ERROR level', () => { + setVerbosityLevel(VerbosityLevel.ERROR); + + logger.log('info message'); + logger.warn('warning message'); + logger.error('error message'); + logger.debug('debug message'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('error message')); + }); + + it('should show errors and warnings at WARN level', () => { + setVerbosityLevel(VerbosityLevel.WARN); + + logger.log('info message'); + logger.warn('warning message'); + logger.error('error message'); + logger.debug('debug message'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('should show info, warnings, and errors at INFO level', () => { + setVerbosityLevel(VerbosityLevel.INFO); + + logger.log('info message'); + logger.warn('warning message'); + logger.error('error message'); + logger.debug('debug message'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('should show all messages at DEBUG level', () => { + setVerbosityLevel(VerbosityLevel.DEBUG); + + logger.log('info message'); + logger.warn('warning message'); + logger.error('error message'); + logger.debug('debug message'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(2); // info + debug + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('should show nothing at SILENT level except critical errors', () => { + setVerbosityLevel(VerbosityLevel.SILENT); + + logger.log('info message'); + logger.warn('warning message'); + logger.error('error message'); + logger.debug('debug message'); + + // At SILENT level, even regular errors are suppressed + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should show all except debug at VERBOSE level', () => { + setVerbosityLevel(VerbosityLevel.VERBOSE); + + logger.log('info message'); + logger.warn('warning message'); + logger.error('error message'); + logger.debug('debug message'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); // only info + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Backward Compatibility', () => { + it('should support setDebugMode for backward compatibility', () => { + const logger = createLogger('test-module'); + + // Enable debug mode + logger.setDebugMode(true); + expect(getVerbosityLevel()).toBe(VerbosityLevel.DEBUG); + + // Debug messages should now appear + logger.debug('debug message'); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('debug message')); + }); + }); + + describe('Logger Instance Methods', () => { + it('should support per-logger verbosity control', () => { + const logger = createLogger('test-module'); + + // Set verbosity through logger instance + logger.setVerbosity(VerbosityLevel.WARN); + expect(getVerbosityLevel()).toBe(VerbosityLevel.WARN); + + logger.log('info message'); + logger.warn('warning message'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Logger Methods', () => { + const logger = createLogger('test-module'); + + it('should have info() method that works the same as log()', () => { + setVerbosityLevel(VerbosityLevel.INFO); + + logger.info('info message via info()'); + logger.log('info message via log()'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(2); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('info message via info()') + ); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('info message via log()')); + }); + + it('info() method should respect verbosity levels', () => { + setVerbosityLevel(VerbosityLevel.ERROR); + logger.info('hidden info message'); + expect(consoleLogSpy).not.toHaveBeenCalled(); + + setVerbosityLevel(VerbosityLevel.INFO); + logger.info('visible info message'); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Type Guards and Parsing', () => { + it('should correctly identify valid verbosity level strings', () => { + expect(isVerbosityLevel('SILENT')).toBe(true); + expect(isVerbosityLevel('silent')).toBe(true); + expect(isVerbosityLevel('ERROR')).toBe(true); + expect(isVerbosityLevel('error')).toBe(true); + expect(isVerbosityLevel('WARN')).toBe(true); + expect(isVerbosityLevel('warn')).toBe(true); + expect(isVerbosityLevel('INFO')).toBe(true); + expect(isVerbosityLevel('info')).toBe(true); + expect(isVerbosityLevel('VERBOSE')).toBe(true); + expect(isVerbosityLevel('verbose')).toBe(true); + expect(isVerbosityLevel('DEBUG')).toBe(true); + expect(isVerbosityLevel('debug')).toBe(true); + }); + + it('should reject invalid verbosity level strings', () => { + expect(isVerbosityLevel('invalid')).toBe(false); + expect(isVerbosityLevel('trace')).toBe(false); + expect(isVerbosityLevel('log')).toBe(false); + expect(isVerbosityLevel('')).toBe(false); + expect(isVerbosityLevel('123')).toBe(false); + }); + + it('should parse valid verbosity levels correctly', () => { + expect(parseVerbosityLevel('silent')).toBe(VerbosityLevel.SILENT); + expect(parseVerbosityLevel('SILENT')).toBe(VerbosityLevel.SILENT); + expect(parseVerbosityLevel('error')).toBe(VerbosityLevel.ERROR); + expect(parseVerbosityLevel('ERROR')).toBe(VerbosityLevel.ERROR); + expect(parseVerbosityLevel('warn')).toBe(VerbosityLevel.WARN); + expect(parseVerbosityLevel('WARN')).toBe(VerbosityLevel.WARN); + expect(parseVerbosityLevel('info')).toBe(VerbosityLevel.INFO); + expect(parseVerbosityLevel('INFO')).toBe(VerbosityLevel.INFO); + expect(parseVerbosityLevel('verbose')).toBe(VerbosityLevel.VERBOSE); + expect(parseVerbosityLevel('VERBOSE')).toBe(VerbosityLevel.VERBOSE); + expect(parseVerbosityLevel('debug')).toBe(VerbosityLevel.DEBUG); + expect(parseVerbosityLevel('DEBUG')).toBe(VerbosityLevel.DEBUG); + }); + + it('should return undefined for invalid verbosity levels', () => { + expect(parseVerbosityLevel('invalid')).toBeUndefined(); + expect(parseVerbosityLevel('trace')).toBeUndefined(); + expect(parseVerbosityLevel('')).toBeUndefined(); + }); + + it('should have correct VERBOSITY_MAP structure', () => { + expect(VERBOSITY_MAP).toEqual({ + silent: VerbosityLevel.SILENT, + error: VerbosityLevel.ERROR, + warn: VerbosityLevel.WARN, + info: VerbosityLevel.INFO, + verbose: VerbosityLevel.VERBOSE, + debug: VerbosityLevel.DEBUG, + }); + }); + + it('should parse using VERBOSITY_MAP', () => { + Object.entries(VERBOSITY_MAP).forEach(([key, value]) => { + expect(parseVerbosityLevel(key)).toBe(value); + expect(parseVerbosityLevel(key.toUpperCase())).toBe(value); + }); + }); + }); + + describe('Helper Functions', () => { + it('isDebugEnabled should return true only for DEBUG level', () => { + setVerbosityLevel(VerbosityLevel.SILENT); + expect(isDebugEnabled()).toBe(false); + + setVerbosityLevel(VerbosityLevel.ERROR); + expect(isDebugEnabled()).toBe(false); + + setVerbosityLevel(VerbosityLevel.WARN); + expect(isDebugEnabled()).toBe(false); + + setVerbosityLevel(VerbosityLevel.INFO); + expect(isDebugEnabled()).toBe(false); + + setVerbosityLevel(VerbosityLevel.VERBOSE); + expect(isDebugEnabled()).toBe(false); + + setVerbosityLevel(VerbosityLevel.DEBUG); + expect(isDebugEnabled()).toBe(true); + }); + + it('isVerbose should return true for VERBOSE and DEBUG levels', () => { + setVerbosityLevel(VerbosityLevel.SILENT); + expect(isVerbose()).toBe(false); + + setVerbosityLevel(VerbosityLevel.ERROR); + expect(isVerbose()).toBe(false); + + setVerbosityLevel(VerbosityLevel.WARN); + expect(isVerbose()).toBe(false); + + setVerbosityLevel(VerbosityLevel.INFO); + expect(isVerbose()).toBe(false); + + setVerbosityLevel(VerbosityLevel.VERBOSE); + expect(isVerbose()).toBe(true); + + setVerbosityLevel(VerbosityLevel.DEBUG); + expect(isVerbose()).toBe(true); + }); + }); +}); diff --git a/web/src/test/unit/verbosity-parser.test.ts b/web/src/test/unit/verbosity-parser.test.ts new file mode 100644 index 00000000..a6191f3b --- /dev/null +++ b/web/src/test/unit/verbosity-parser.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { VerbosityLevel } from '../../server/utils/logger'; +import { parseVerbosityFromEnv } from '../../server/utils/verbosity-parser'; + +describe('Verbosity Parser', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Reset environment variables before each test + process.env = { ...originalEnv }; + delete process.env.VIBETUNNEL_LOG_LEVEL; + delete process.env.VIBETUNNEL_DEBUG; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('parseVerbosityFromEnv', () => { + it('should return undefined when no environment variables are set', () => { + expect(parseVerbosityFromEnv()).toBeUndefined(); + }); + + it('should parse VIBETUNNEL_LOG_LEVEL correctly', () => { + process.env.VIBETUNNEL_LOG_LEVEL = 'info'; + expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.INFO); + + process.env.VIBETUNNEL_LOG_LEVEL = 'DEBUG'; + expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.DEBUG); + + process.env.VIBETUNNEL_LOG_LEVEL = 'silent'; + expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.SILENT); + }); + + it('should return undefined for invalid VIBETUNNEL_LOG_LEVEL', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + process.env.VIBETUNNEL_LOG_LEVEL = 'invalid'; + expect(parseVerbosityFromEnv()).toBeUndefined(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(2); + expect(consoleWarnSpy).toHaveBeenCalledWith('Invalid VIBETUNNEL_LOG_LEVEL: invalid'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Valid levels: silent, error, warn, info, verbose, debug' + ); + + consoleWarnSpy.mockRestore(); + }); + + it('should handle VIBETUNNEL_DEBUG=1', () => { + process.env.VIBETUNNEL_DEBUG = '1'; + expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.DEBUG); + }); + + it('should handle VIBETUNNEL_DEBUG=true', () => { + process.env.VIBETUNNEL_DEBUG = 'true'; + expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.DEBUG); + }); + + it('should ignore VIBETUNNEL_DEBUG when set to other values', () => { + process.env.VIBETUNNEL_DEBUG = '0'; + expect(parseVerbosityFromEnv()).toBeUndefined(); + + process.env.VIBETUNNEL_DEBUG = 'false'; + expect(parseVerbosityFromEnv()).toBeUndefined(); + + process.env.VIBETUNNEL_DEBUG = 'yes'; + expect(parseVerbosityFromEnv()).toBeUndefined(); + }); + + it('should prioritize VIBETUNNEL_LOG_LEVEL over VIBETUNNEL_DEBUG', () => { + process.env.VIBETUNNEL_LOG_LEVEL = 'warn'; + process.env.VIBETUNNEL_DEBUG = '1'; + expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.WARN); + }); + + it('should return DEBUG when VIBETUNNEL_LOG_LEVEL is invalid but VIBETUNNEL_DEBUG is set', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + process.env.VIBETUNNEL_LOG_LEVEL = 'invalid'; + process.env.VIBETUNNEL_DEBUG = '1'; + expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.DEBUG); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(2); + expect(consoleWarnSpy).toHaveBeenCalledWith('Invalid VIBETUNNEL_LOG_LEVEL: invalid'); + + consoleWarnSpy.mockRestore(); + }); + }); +}); diff --git a/web/src/test/utils/server-utils.ts b/web/src/test/utils/server-utils.ts index 84ebb453..5e2bff34 100644 --- a/web/src/test/utils/server-utils.ts +++ b/web/src/test/utils/server-utils.ts @@ -163,6 +163,8 @@ export async function startTestServer(config: ServerConfig = {}): Promise<Server VIBETUNNEL_CONTROL_DIR: controlDir, NODE_ENV: 'production', FORCE_COLOR: '0', + // Ensure INFO verbosity for tests to see server startup messages + VIBETUNNEL_LOG_LEVEL: env.VIBETUNNEL_LOG_LEVEL || env.VIBETUNNEL_DEBUG ? undefined : 'info', ...env, };