diff --git a/.gitignore b/.gitignore index 02b1cf1..c0d7105 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ tests/.env.local # Coverage reports coverage/ + +# Build output +dist/ +*.tsbuildinfo diff --git a/README.md b/README.md index fe25fed..f54802a 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ The plugin automatically tries multiple TTS engines in order, falling back if on ### System Integration - **Native Desktop Notifications**: Windows (Toast), macOS (Notification Center), and Linux (notify-send) support - **Native Edge TTS**: No external dependencies (Python/pip) required -- **Focus Detection** (macOS): Suppresses notifications when terminal is focused +- **Focus Detection** (Cross-platform): Suppresses notifications when terminal is focused (Windows, macOS, Linux) - **Webhook Integration**: Receive notifications on Discord or any custom webhook endpoint when tasks finish or need attention - **Themed Sound Packs**: Use custom sound collections (e.g., Warcraft, StarCraft) by simply pointing to a directory - **Per-Project Sounds**: Assign unique sounds to different projects for easy identification @@ -155,8 +155,9 @@ If you prefer to create the config manually, add a `smart-voice-notify.jsonc` fi "ttsReminderDelaySeconds": 30, "enableFollowUpReminders": true, - // Focus Detection (macOS only) - "suppressWhenFocused": true, + // Focus Detection (suppress notifications when terminal is focused) + // Default: false (notifications always play) + "suppressWhenFocused": false, "alwaysNotify": false, // AI-generated messages (optional - requires local AI server) @@ -325,7 +326,7 @@ You can replace individual sound files with entire "Sound Themes" (like the clas | **TTS (Windows SAPI)** | ✅ | ❌ | ❌ | | **TTS (macOS Say)** | ❌ | ✅ | ❌ | | **Desktop Notifications** | ✅ | ✅ | ✅ (req libnotify) | -| **Focus Detection** | ❌ | ✅ | ❌ | +| **Focus Detection** | ✅ | ✅ | ✅ | | **Webhook Integration** | ✅ | ✅ | ✅ | | **Wake Monitor** | ✅ | ✅ | ✅ (X11/Gnome) | | **Volume Control** | ✅ | ✅ | ✅ (Pulse/ALSA) | @@ -373,15 +374,15 @@ You can replace individual sound files with entire "Sound Themes" (like the clas - **Linux**: `paplay` or `aplay` ### For Focus Detection -Focus detection suppresses sound and desktop notifications when the terminal is focused. +Focus detection suppresses sound and desktop notifications when the terminal is focused. Also detects minimized or hidden terminal windows. | Platform | Support | Notes | |----------|---------|-------| | **macOS** | ✅ Full | Uses AppleScript to detect frontmost application | -| **Windows** | ❌ Not supported | No reliable API available | -| **Linux** | ❌ Not supported | Varies by desktop environment | +| **Windows** | ✅ Full | Uses native window focus and visibility detection | +| **Linux** | ✅ Full | Uses `xdotool` / `xprop` (X11) or `gdbus` (Wayland) | -> **Note**: On unsupported platforms, notifications are always sent (fail-open behavior). TTS reminders are never suppressed, even when focused, since users may step away after seeing the toast. +> **Note**: If focus detection fails on any platform, notifications are still sent (fail-open behavior). TTS reminders are never suppressed, even when focused, since users may step away after seeing the toast. ### For Webhook Notifications - **Discord**: Full support for Discord's webhook embed format. @@ -555,7 +556,7 @@ When a user asks you to set up this plugin, follow these steps: ### Platform-Specific Notes - **Windows**: All features supported, SAPI as offline fallback -- **macOS**: Focus detection available, `say` command as offline fallback +- **macOS**: `say` command as offline fallback - **Linux**: Requires `libnotify-bin` for desktop notifications, no offline TTS fallback ### TTS Fallback Chain diff --git a/bunfig.toml b/bunfig.toml index 33b8dae..f8ee3ef 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -6,7 +6,7 @@ # Test patterns: ["**/*.test.js", "**/*.spec.js"] (Bun's default) # Preload file for test environment setup -preload = ["./tests/setup.js"] +preload = ["./tests/setup.ts"] # Test execution timeout in milliseconds (10 seconds) timeout = 10000 diff --git a/example.config.jsonc b/example.config.jsonc index d5e0eec..39adfbf 100644 --- a/example.config.jsonc +++ b/example.config.jsonc @@ -17,7 +17,7 @@ // ============================================================ // Internal version tracking - DO NOT REMOVE - "_configVersion": "1.2.5", + "_configVersion": "1.3.3", // ============================================================ // PLUGIN ENABLE/DISABLE @@ -78,7 +78,7 @@ // ============================================================ // 'openai' - OpenAI-compatible TTS (Self-hosted/Cloud, e.g. Kokoro, LocalAI) // 'elevenlabs' - Best quality, anime-like voices (requires API key, free tier: 10k chars/month) - // 'edge' - Good quality neural voices (Free, Native Node.js implementation) + // 'edge' - Good quality neural voices (Python edge-tts CLI RECOMMENDED, with msedge-tts npm fallback) // 'sapi' - Windows built-in voices (free, offline, robotic) "ttsEngine": "elevenlabs", @@ -109,9 +109,10 @@ "elevenLabsStyle": 0.5, // Style exaggeration (higher = more expressive) // ============================================================ - // EDGE TTS SETTINGS (Free Neural Voices - Fallback) + // EDGE TTS SETTINGS (Free Neural Voices) // ============================================================ - // Native Node.js implementation (No external dependencies) + // Uses Python edge-tts CLI (RECOMMENDED, pip install edge-tts) with automatic + // fallback to msedge-tts npm package if Python is not available. // Voice options (run 'edge-tts --list-voices' to see all): // 'en-US-AnaNeural' - Young, cute, cartoon-like (RECOMMENDED) @@ -133,6 +134,11 @@ // Voice (run PowerShell to list all installed voices): // Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() | % { $_.VoiceInfo.Name } + // + // Common Windows voices: + // 'Microsoft Zira Desktop' - Female, US English + // 'Microsoft David Desktop' - Male, US English + // 'Microsoft Hazel Desktop' - Female, UK English "sapiVoice": "Microsoft Zira Desktop", // Speech rate: -10 (slowest) to +10 (fastest), 0 is normal @@ -221,13 +227,22 @@ ], // ============================================================ - // PERMISSION BATCHING + // PERMISSION BATCHING (Multiple permissions at once) // ============================================================ + // When multiple permissions arrive simultaneously, batch them into one notification + // This prevents overlapping sounds when 5+ permissions come at once + + // Batch window (ms) - how long to wait for more permissions before notifying "permissionBatchWindowMs": 800, // ============================================================ - // QUESTION TOOL MESSAGES (SDK v1.1.7+) + // QUESTION TOOL SETTINGS (SDK v1.1.7+ - Agent asking user questions) // ============================================================ + // The "question" tool allows the LLM to ask users questions during execution. + // This is useful for gathering preferences, clarifying instructions, or getting + // decisions on implementation choices. + + // Messages when agent asks user a question "questionTTSMessages": [ "Hey! I have a question for you. Please check your screen.", "Attention! I need your input to continue.", @@ -256,12 +271,19 @@ "Still waiting for your answers on {count} questions! The task is on hold.", "Your input is needed! {count} questions are pending your response." ], + // Delay (in seconds) before question reminder fires "questionReminderDelaySeconds": 25, + + // Question batch window (ms) - how long to wait for more questions before notifying "questionBatchWindowMs": 800, // ============================================================ - // ERROR NOTIFICATION SETTINGS + // ERROR NOTIFICATION SETTINGS (Session Errors) // ============================================================ + // Notify users when the agent encounters an error during execution. + // Error notifications use more urgent messaging to get user attention. + + // Messages when agent encounters an error "errorTTSMessages": [ "Oops! Something went wrong. Please check for errors.", "Alert! The agent encountered an error and needs your attention.", @@ -290,17 +312,51 @@ "Still waiting! {count} errors need your attention.", "Don't forget! There are {count} unresolved errors in your session." ], + // Delay (in seconds) before error reminder fires (shorter than idle for urgency) "errorReminderDelaySeconds": 20, // ============================================================ - // AI MESSAGE GENERATION + // AI MESSAGE GENERATION (OpenAI-Compatible Endpoints) // ============================================================ + // Use a local/self-hosted AI to generate dynamic notification messages + // instead of using preset static messages. The AI generates the text, + // which is then spoken by your configured TTS engine (ElevenLabs, Edge, etc.) + // + // Supports: Ollama, LM Studio, LocalAI, vLLM, llama.cpp, Jan.ai, and any + // OpenAI-compatible endpoint. You provide your own endpoint URL and API key. + "enableAIMessages": false, + + // Your AI server endpoint URL (e.g., Ollama: http://localhost:11434/v1) + // Common endpoints: + // Ollama: http://localhost:11434/v1 + // LM Studio: http://localhost:1234/v1 + // LocalAI: http://localhost:8080/v1 + // vLLM: http://localhost:8000/v1 + // Jan.ai: http://localhost:1337/v1 "aiEndpoint": "http://localhost:11434/v1", + + // Model name to use (depends on what's loaded in your AI server) + // Examples: "llama3", "mistral", "phi3", "gemma2", "qwen2" "aiModel": "llama3", + + // API key for your AI server (leave empty for Ollama/LM Studio/LocalAI) + // Only needed if your server requires authentication "aiApiKey": "", + + // Request timeout in milliseconds (local AI can be slow on first request) "aiTimeout": 15000, + + // Fallback to static preset messages if AI generation fails "aiFallbackToStatic": true, + + // Enable context-aware AI messages (includes project name, task title, and change summary) + // When enabled, AI-generated notifications will include relevant context like: + // - Project name (e.g., "Your work on MyProject is complete!") + // - Task/session title if available + // - Change summary (files modified, lines added/deleted) + // Disabled by default - enable this for more personalized notifications + "enableContextAwareAI": false, "aiPrompts": { "idle": "Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.", "permission": "Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.", @@ -332,35 +388,91 @@ // ============================================================ // DESKTOP NOTIFICATION SETTINGS // ============================================================ + // Native desktop notifications (Windows Toast, macOS Notification Center, Linux notify-send) + // These appear as system notifications alongside sound and TTS. + // + // Note: On Linux, you may need to install libnotify-bin: + // Ubuntu/Debian: sudo apt install libnotify-bin + // Fedora: sudo dnf install libnotify + // Arch: sudo pacman -S libnotify + + // Enable native desktop notifications "enableDesktopNotification": true, + + // How long the notification stays on screen (in seconds) + // Note: Some platforms may ignore this (especially Windows 10+) "desktopNotificationTimeout": 5, + + // Include the project name in notification titles for easier identification + // Example: "OpenCode - MyProject" instead of just "OpenCode" "showProjectInNotification": true, // ============================================================ // FOCUS DETECTION SETTINGS // ============================================================ - "suppressWhenFocused": true, + // Suppress sound/desktop notifications when terminal window is focused. + // Cross-platform: Windows, macOS, and Linux (X11 via xdotool/xprop, Wayland via gdbus). + // Default: false (notifications always play regardless of focus) + // Set to true to avoid notification spam when actively working in terminal + "suppressWhenFocused": false, "alwaysNotify": false, // ============================================================ - // WEBHOOK NOTIFICATION SETTINGS + // WEBHOOK NOTIFICATION SETTINGS (Discord/Generic) // ============================================================ + // Send notifications to a Discord webhook or any compatible endpoint. + // This allows you to receive notifications on your phone or other devices. + + // Enable webhook notifications "enableWebhook": false, + + // Webhook URL (e.g., https://discord.com/api/webhooks/...) "webhookUrl": "", + + // Username to show in the webhook message "webhookUsername": "OpenCode Notify", + + // Events that should trigger a webhook notification + // Options: "idle", "permission", "error", "question" "webhookEvents": ["idle", "permission", "error", "question"], + + // Mention @everyone on permission requests (Discord only) "webhookMentionOnPermission": false, // ============================================================ - // SOUND THEME SETTINGS + // SOUND THEME SETTINGS (Themed Sound Packs) // ============================================================ + // Configure a directory containing custom sound files for notifications. + // This allows you to use themed sound packs (e.g., Warcraft, StarCraft, etc.) + // + // Directory structure should contain: + // /path/to/theme/idle/ - Sounds for task completion + // /path/to/theme/permission/ - Sounds for permission requests + // /path/to/theme/error/ - Sounds for agent errors + // /path/to/theme/question/ - Sounds for agent questions + // + // If a specific event folder is missing, it falls back to default sounds. + + // Path to your custom sound theme directory (absolute path recommended) "soundThemeDir": "", + + // Pick a random sound from the appropriate theme folder for each notification "randomizeSoundFromTheme": true, // ============================================================ // PER-PROJECT SOUND SETTINGS // ============================================================ + // Assign a unique notification sound to each project based on its path. + // This helps you distinguish which project is notifying you when working + // on multiple tasks simultaneously. + // + // Note: Requires sounds named 'ding1.mp3' through 'ding6.mp3' in your + // assets/ folder. If disabled, default sound files are used. + + // Enable unique sounds per project "perProjectSounds": false, + + // Seed value to change sound assignments (0-999) "projectSoundSeed": 0, // General options diff --git a/package.json b/package.json index ba498dc..0e7e573 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,14 @@ { "name": "opencode-smart-voice-notify", - "version": "1.3.2", + "version": "1.3.3", "description": "Smart voice notification plugin for OpenCode with multiple TTS engines (ElevenLabs, Edge TTS, Windows SAPI), AI-generated dynamic messages, and intelligent reminder system", - "main": "index.js", + "main": "dist/index.js", + "types": "dist/index.d.ts", "type": "module", "scripts": { + "build": "tsc -p tsconfig.build.json", + "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly", + "typecheck": "tsc --noEmit", "test": "bun test", "test:watch": "bun test --watch", "test:coverage": "bun test --coverage" @@ -30,10 +34,8 @@ "local-ai" ], "files": [ - "index.js", - "util/", - "assets/", - "example.config.jsonc" + "dist/", + "assets/" ], "repository": { "type": "git", @@ -48,12 +50,18 @@ "bun": ">=1.0.0" }, "dependencies": { - "@elevenlabs/elevenlabs-js": "^2.32.0", + "@elevenlabs/elevenlabs-js": "^2.36.0", "detect-terminal": "^2.0.0", "msedge-tts": "^2.0.4", "node-notifier": "^10.0.1" }, "peerDependencies": { "@opencode-ai/plugin": "^1.1.8" + }, + "devDependencies": { + "@types/node": "^20.19.33", + "@types/node-notifier": "^8.0.5", + "bun-types": "^1.3.9", + "typescript": "^5.9.3" } } diff --git a/index.js b/src/index.ts similarity index 84% rename from index.js rename to src/index.ts index f2bf26f..6db836e 100644 --- a/index.js +++ b/src/index.ts @@ -1,6 +1,13 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; + +import type { AIContext, NotificationEventType, PluginConfig } from './types/config.js'; +import type { PendingReminder, PluginState, ScheduleReminderOptions, SmartNotifyOptions } from './types/events.js'; +import type { DesktopNotifyOptions, WebhookNotifyOptions } from './types/notification.js'; +import type { PluginEvent, PluginHandlers, PluginInitParams, Session } from './types/opencode-sdk.js'; +import type { TTSAPI } from './types/tts.js'; + import { createTTS, getTTSConfig } from './util/tts.js'; import { getSmartMessage } from './util/ai-messages.js'; import { notifyTaskComplete, notifyPermissionRequest, notifyQuestion, notifyError } from './util/desktop-notify.js'; @@ -9,6 +16,26 @@ import { isTerminalFocused } from './util/focus-detect.js'; import { pickThemeSound } from './util/sound-theme.js'; import { getProjectSound } from './util/per-project-sound.js'; +type ToastVariant = 'info' | 'success' | 'warning' | 'error'; + +interface NotificationMetaOptions { + count?: number; + sessionId?: string; +} + +interface MessageInfo { + id?: string; + role?: string; + time?: { + created?: number; + }; +} + +const getErrorMessage = (error: unknown): string => { + const maybeError = error as { message?: unknown }; + return String(maybeError?.message ?? error); +}; + /** * OpenCode Smart Voice Notify Plugin * @@ -27,8 +54,14 @@ import { getProjectSound } from './util/per-project-sound.js'; * * @type {import("@opencode-ai/plugin").Plugin} */ -export default async function SmartVoiceNotifyPlugin({ project, client, $, directory, worktree }) { - let config = getTTSConfig(); +export default async function SmartVoiceNotifyPlugin({ + project, + client, + $, + directory, + worktree, +}: PluginInitParams): Promise { + let config: PluginConfig = getTTSConfig(); // Derive project name from worktree path since SDK's Project type doesn't have a 'name' property // Example: C:\Repository\opencode-smart-voice-notify -> opencode-smart-voice-notify @@ -52,16 +85,13 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc } const timestamp = new Date().toISOString(); fs.appendFileSync(logFile, `[${timestamp}] Plugin disabled via config (enabled: ${config.enabled}) - no event handlers registered\n`); - } catch (e) {} + } catch {} } return {}; } - let tts = createTTS({ $, client }); - - - const platform = os.platform(); + let tts: TTSAPI = createTTS({ $, client }); const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode'); const logsDir = path.join(configDir, 'logs'); @@ -71,28 +101,28 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc if (config.debugLog && !fs.existsSync(logsDir)) { try { fs.mkdirSync(logsDir, { recursive: true }); - } catch (e) { + } catch { // Silently fail - logging is optional } } // Track pending TTS reminders (can be cancelled if user responds) - const pendingReminders = new Map(); + const pendingReminders: PluginState['pendingReminders'] = new Map(); // Track last user activity time - let lastUserActivityTime = Date.now(); + let lastUserActivityTime: PluginState['lastUserActivityTime'] = Date.now(); // Track seen user message IDs to avoid treating message UPDATES as new user activity // Key insight: message.updated fires for EVERY modification to a message, not just new messages // We only want to treat the FIRST occurrence of each user message as "user activity" - const seenUserMessageIds = new Set(); + const seenUserMessageIds: PluginState['seenUserMessageIds'] = new Set(); // Track the timestamp of when session went idle, to detect post-idle user messages - let lastSessionIdleTime = 0; + let lastSessionIdleTime: PluginState['lastSessionIdleTime'] = 0; // Track active permission request to prevent race condition where user responds // before async notification code runs. Set on permission.updated, cleared on permission.replied. - let activePermissionId = null; + let activePermissionId: PluginState['activePermissionId'] = null; // ======================================== // IDLE EVENT DEBOUNCING STATE @@ -101,7 +131,11 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc // ======================================== // Map of sessionID -> timestamp of last processed idle notification - const lastIdleNotificationTime = new Map(); + const lastIdleNotificationTime: PluginState['lastIdleNotificationTime'] = new Map(); + + // Cache session data to reduce repeated session.get API calls. + const sessionCache = new Map(); + const SESSION_CACHE_TTL = 30000; // 30 seconds TTL // Debounce window in milliseconds - skip duplicate idle events within this window // 5 seconds is long enough to catch rapid duplicates but short enough to allow @@ -114,10 +148,10 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc // ======================================== // Array of permission IDs waiting to be notified (collected during batch window) - let pendingPermissionBatch = []; + let pendingPermissionBatch: PluginState['pendingPermissionBatch'] = []; // Timeout ID for the batch window (debounce timer) - let permissionBatchTimeout = null; + let permissionBatchTimeout: PluginState['permissionBatchTimeout'] = null; // Batch window duration in milliseconds (how long to wait for more permissions) const PERMISSION_BATCH_WINDOW_MS = config.permissionBatchWindowMs || 800; @@ -129,27 +163,76 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc // Array of question request objects waiting to be notified (collected during batch window) // Each object contains { id: string, questionCount: number } to track actual question count - let pendingQuestionBatch = []; + let pendingQuestionBatch: PluginState['pendingQuestionBatch'] = []; // Timeout ID for the question batch window (debounce timer) - let questionBatchTimeout = null; + let questionBatchTimeout: PluginState['questionBatchTimeout'] = null; // Batch window duration in milliseconds (how long to wait for more questions) const QUESTION_BATCH_WINDOW_MS = config.questionBatchWindowMs || 800; // Track active question request to prevent race condition where user responds // before async notification code runs. Set on question.asked, cleared on question.replied/rejected. - let activeQuestionId = null; + let activeQuestionId: PluginState['activeQuestionId'] = null; /** * Write debug message to log file */ - const debugLog = (message) => { + const debugLog = (message: string): void => { if (!config.debugLog) return; try { const timestamp = new Date().toISOString(); fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`); - } catch (e) {} + } catch {} + }; + + /** + * Cleanup expired session cache entries to prevent memory leaks. + */ + const cleanupExpiredSessionCache = (): number => { + const now = Date.now(); + let removedCount = 0; + + for (const [cachedSessionID, entry] of sessionCache.entries()) { + if ((now - entry.timestamp) > SESSION_CACHE_TTL) { + sessionCache.delete(cachedSessionID); + removedCount++; + } + } + + return removedCount; + }; + + /** + * Get session data from cache when available, otherwise fetch and cache it. + */ + const getSessionDataWithCache = async ( + sessionID: string, + eventType: 'session.idle' | 'session.error', + ): Promise => { + const now = Date.now(); + const cleanedEntries = cleanupExpiredSessionCache(); + if (cleanedEntries > 0) { + debugLog(`${eventType}: cleaned ${cleanedEntries} expired session cache entr${cleanedEntries === 1 ? 'y' : 'ies'}`); + } + + const cachedEntry = sessionCache.get(sessionID); + if (cachedEntry && (now - cachedEntry.timestamp) <= SESSION_CACHE_TTL) { + debugLog(`${eventType}: session cache hit for ${sessionID} (age=${now - cachedEntry.timestamp}ms)`); + return cachedEntry.data; + } + + if (cachedEntry) { + debugLog(`${eventType}: session cache stale for ${sessionID} (age=${now - cachedEntry.timestamp}ms, ttl=${SESSION_CACHE_TTL}ms)`); + } else { + debugLog(`${eventType}: session cache miss for ${sessionID}`); + } + + const session = await client.session.get({ path: { id: sessionID } }); + const sessionData = session?.data ?? null; + sessionCache.set(sessionID, { data: sessionData, timestamp: now }); + debugLog(`${eventType}: cached session details for ${sessionID} (ttl=${SESSION_CACHE_TTL}ms)`); + return sessionData; }; /** @@ -161,7 +244,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc * * @returns {Promise} True if notifications should be suppressed */ - const shouldSuppressNotification = async () => { + const shouldSuppressNotification = async (): Promise => { // If alwaysNotify is true, never suppress if (config.alwaysNotify) { debugLog('shouldSuppressNotification: alwaysNotify=true, not suppressing'); @@ -181,8 +264,8 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc debugLog('shouldSuppressNotification: terminal is focused, suppressing sound/desktop notifications'); return true; } - } catch (e) { - debugLog(`shouldSuppressNotification: focus detection error: ${e.message}`); + } catch (error) { + debugLog(`shouldSuppressNotification: focus detection error: ${getErrorMessage(error)}`); // On error, fail open (don't suppress) } @@ -192,17 +275,17 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc /** * Get a random message from an array of messages */ - const getRandomMessage = (messages) => { + const getRandomMessage = (messages: string[] | null | undefined): string => { if (!Array.isArray(messages) || messages.length === 0) { return 'Notification'; } - return messages[Math.floor(Math.random() * messages.length)]; + return messages[Math.floor(Math.random() * messages.length)]!; }; /** * Show a TUI toast notification */ - const showToast = async (message, variant = 'info', duration = 5000) => { + const showToast = async (message: string, variant: ToastVariant = 'info', duration = 5000): Promise => { if (!config.enableToast) return; try { if (typeof client?.tui?.showToast === 'function') { @@ -214,7 +297,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc } }); } - } catch (e) {} + } catch {} }; /** @@ -225,42 +308,42 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc * @param {string} message - Notification message * @param {object} options - Additional options (count for permission/question/error) */ - const sendDesktopNotify = (type, message, options = {}) => { + const sendDesktopNotify = (type: NotificationEventType, message: string, options: NotificationMetaOptions = {}): void => { if (!config.enableDesktopNotification) return; try { // Build options with project name if configured // Note: SDK's Project type doesn't have 'name' property, so we use derivedProjectName - const notifyOptions = { + const notifyOptions: DesktopNotifyOptions = { projectName: config.showProjectInNotification && derivedProjectName ? derivedProjectName : undefined, timeout: config.desktopNotificationTimeout || 5, debugLog: config.debugLog, - count: options.count || 1 + count: options.count || 1, }; // Fire and forget (no await) - desktop notification should not block other operations // Use the appropriate helper function based on notification type if (type === 'idle') { - notifyTaskComplete(message, notifyOptions).catch(e => { - debugLog(`Desktop notification error (idle): ${e.message}`); + notifyTaskComplete(message, notifyOptions).catch((error: unknown) => { + debugLog(`Desktop notification error (idle): ${getErrorMessage(error)}`); }); } else if (type === 'permission') { - notifyPermissionRequest(message, notifyOptions).catch(e => { - debugLog(`Desktop notification error (permission): ${e.message}`); + notifyPermissionRequest(message, notifyOptions).catch((error: unknown) => { + debugLog(`Desktop notification error (permission): ${getErrorMessage(error)}`); }); } else if (type === 'question') { - notifyQuestion(message, notifyOptions).catch(e => { - debugLog(`Desktop notification error (question): ${e.message}`); + notifyQuestion(message, notifyOptions).catch((error: unknown) => { + debugLog(`Desktop notification error (question): ${getErrorMessage(error)}`); }); } else if (type === 'error') { - notifyError(message, notifyOptions).catch(e => { - debugLog(`Desktop notification error (error): ${e.message}`); + notifyError(message, notifyOptions).catch((error: unknown) => { + debugLog(`Desktop notification error (error): ${getErrorMessage(error)}`); }); } debugLog(`sendDesktopNotify: sent ${type} notification`); - } catch (e) { - debugLog(`sendDesktopNotify error: ${e.message}`); + } catch (error) { + debugLog(`sendDesktopNotify error: ${getErrorMessage(error)}`); } }; @@ -272,7 +355,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc * @param {string} message - Notification message * @param {object} options - Additional options (count, sessionId) */ - const sendWebhookNotify = (type, message, options = {}) => { + const sendWebhookNotify = (type: NotificationEventType, message: string, options: NotificationMetaOptions = {}): void => { if (!config.enableWebhook || !config.webhookUrl) return; // Check if this event type is enabled in webhookEvents @@ -283,37 +366,37 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc try { // Note: SDK's Project type doesn't have 'name' property, so we use derivedProjectName - const webhookOptions = { - projectName: derivedProjectName, + const webhookOptions: WebhookNotifyOptions = { + projectName: derivedProjectName ?? undefined, sessionId: options.sessionId, count: options.count || 1, username: config.webhookUsername, debugLog: config.debugLog, - mention: type === 'permission' ? config.webhookMentionOnPermission : false + mention: type === 'permission' ? config.webhookMentionOnPermission : false, }; // Fire and forget (no await) if (type === 'idle') { - notifyWebhookIdle(config.webhookUrl, message, webhookOptions).catch(e => { - debugLog(`Webhook notification error (idle): ${e.message}`); + notifyWebhookIdle(config.webhookUrl, message, webhookOptions).catch((error: unknown) => { + debugLog(`Webhook notification error (idle): ${getErrorMessage(error)}`); }); } else if (type === 'permission') { - notifyWebhookPermission(config.webhookUrl, message, webhookOptions).catch(e => { - debugLog(`Webhook notification error (permission): ${e.message}`); + notifyWebhookPermission(config.webhookUrl, message, webhookOptions).catch((error: unknown) => { + debugLog(`Webhook notification error (permission): ${getErrorMessage(error)}`); }); } else if (type === 'question') { - notifyWebhookQuestion(config.webhookUrl, message, webhookOptions).catch(e => { - debugLog(`Webhook notification error (question): ${e.message}`); + notifyWebhookQuestion(config.webhookUrl, message, webhookOptions).catch((error: unknown) => { + debugLog(`Webhook notification error (question): ${getErrorMessage(error)}`); }); } else if (type === 'error') { - notifyWebhookError(config.webhookUrl, message, webhookOptions).catch(e => { - debugLog(`Webhook notification error (error): ${e.message}`); + notifyWebhookError(config.webhookUrl, message, webhookOptions).catch((error: unknown) => { + debugLog(`Webhook notification error (error): ${getErrorMessage(error)}`); }); } debugLog(`sendWebhookNotify: sent ${type} notification`); - } catch (e) { - debugLog(`sendWebhookNotify error: ${e.message}`); + } catch (error) { + debugLog(`sendWebhookNotify error: ${getErrorMessage(error)}`); } }; @@ -323,7 +406,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc * @param {number} loops - Number of times to loop * @param {string} eventType - Event type for theme support (idle, permission, error, question) */ - const playSound = async (soundFile, loops = 1, eventType = null) => { + const playSound = async (soundFile: string, loops = 1, eventType: NotificationEventType | null = null): Promise => { if (!config.enableSound) return; try { let soundPath = soundFile; @@ -370,8 +453,8 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc await tts.forceVolume(); await tts.playAudioFile(finalPath, loops); debugLog(`playSound: played ${finalPath} (${loops}x)`); - } catch (e) { - debugLog(`playSound error: ${e.message}`); + } catch (error) { + debugLog(`playSound error: ${getErrorMessage(error)}`); } }; @@ -379,7 +462,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc /** * Cancel any pending TTS reminder for a given type */ - const cancelPendingReminder = (type) => { + const cancelPendingReminder = (type: NotificationEventType): void => { const existing = pendingReminders.get(type); if (existing) { clearTimeout(existing.timeoutId); @@ -391,7 +474,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc /** * Cancel all pending TTS reminders (called on user activity) */ - const cancelAllPendingReminders = () => { + const cancelAllPendingReminders = (): void => { for (const [type, reminder] of pendingReminders.entries()) { clearTimeout(reminder.timeoutId); debugLog(`cancelAllPendingReminders: cancelled ${type}`); @@ -406,7 +489,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc * @param {string} _message - DEPRECATED: No longer used (AI message is generated when reminder fires) * @param {object} options - Additional options (fallbackSound, permissionCount, questionCount, errorCount, aiContext) */ - const scheduleTTSReminder = (type, _message, options = {}) => { + const scheduleTTSReminder = (type: NotificationEventType, _message: string | null, options: ScheduleReminderOptions = {}): void => { // Check if TTS reminders are enabled if (!config.enableTTSReminder) { debugLog(`scheduleTTSReminder: TTS reminders disabled`); @@ -432,7 +515,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc } // Get delay from config (in seconds, convert to ms) - let delaySeconds; + let delaySeconds: number; if (type === 'permission') { delaySeconds = config.permissionReminderDelaySeconds || config.ttsReminderDelaySeconds || 30; } else if (type === 'question') { @@ -451,7 +534,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc const itemCount = options.permissionCount || options.questionCount || options.errorCount || 1; // Store AI context for context-aware follow-up messages - const aiContext = options.aiContext || {}; + const aiContext: AIContext = options.aiContext || {}; debugLog(`scheduleTTSReminder: scheduling ${type} TTS in ${delaySeconds}s (count=${itemCount})`); @@ -484,7 +567,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc } else if (type === 'question') { reminderMessage = await getQuestionMessage(storedCount, true, storedAiContext); } else if (type === 'error') { - reminderMessage = await getErrorMessage(storedCount, true, storedAiContext); + reminderMessage = await getErrorNotificationMessage(storedCount, true, storedAiContext); } else { // Pass stored AI context for idle reminders (context-aware AI feature) reminderMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages, storedAiContext); @@ -543,7 +626,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc } else if (type === 'question') { followUpMessage = await getQuestionMessage(followUpStoredCount, true, followUpAiContext); } else if (type === 'error') { - followUpMessage = await getErrorMessage(followUpStoredCount, true, followUpAiContext); + followUpMessage = await getErrorNotificationMessage(followUpStoredCount, true, followUpAiContext); } else { // Pass stored AI context for idle follow-ups (context-aware AI feature) followUpMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages, followUpAiContext); @@ -568,8 +651,8 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc }); } } - } catch (e) { - debugLog(`scheduleTTSReminder error: ${e.message}`); + } catch (error) { + debugLog(`scheduleTTSReminder error: ${getErrorMessage(error)}`); pendingReminders.delete(type); } }, delayMs); @@ -589,7 +672,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc * @param {string} type - 'idle', 'permission', or 'question' * @param {object} options - Notification options */ - const smartNotify = async (type, options = {}) => { + const smartNotify = async (type: NotificationEventType, options: SmartNotifyOptions = {}): Promise => { const { soundFile, soundLoops = 1, @@ -629,7 +712,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc // Step 3: If TTS-first mode is enabled, also speak immediately if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') { - let immediateMessage; + let immediateMessage: string; if (type === 'permission') { immediateMessage = await getSmartMessage('permission', false, config.permissionTTSMessages); } else if (type === 'question') { @@ -645,6 +728,10 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc } }; + void smartNotify; + + void smartNotify; + /** * Get a count-aware TTS message for permission requests * Uses AI generation when enabled, falls back to static messages @@ -653,7 +740,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc * @param {object} aiContext - Optional context for AI message generation (projectName, sessionTitle, etc.) * @returns {Promise} The formatted message */ - const getPermissionMessage = async (count, isReminder = false, aiContext = {}) => { + const getPermissionMessage = async (count: number, isReminder = false, aiContext: AIContext = {}): Promise => { const messages = isReminder ? config.permissionReminderTTSMessages : config.permissionTTSMessages; @@ -695,7 +782,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc * @param {object} aiContext - Optional context for AI message generation (projectName, sessionTitle, etc.) * @returns {Promise} The formatted message */ - const getQuestionMessage = async (count, isReminder = false, aiContext = {}) => { + const getQuestionMessage = async (count: number, isReminder = false, aiContext: AIContext = {}): Promise => { const messages = isReminder ? config.questionReminderTTSMessages : config.questionTTSMessages; @@ -737,7 +824,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc * @param {object} aiContext - Optional context for AI message generation (projectName, sessionTitle, etc.) * @returns {Promise} The formatted message */ - const getErrorMessage = async (count, isReminder = false, aiContext = {}) => { + const getErrorNotificationMessage = async (count: number, isReminder = false, aiContext: AIContext = {}): Promise => { const messages = isReminder ? config.errorReminderTTSMessages : config.errorTTSMessages; @@ -982,7 +1069,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc }; return { - event: async ({ event }) => { + event: async ({ event }: { event: PluginEvent }): Promise => { // Reload config on every event to support live configuration changes // without requiring a plugin restart. config = getTTSConfig(); @@ -1034,7 +1121,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc // We must only treat NEW user messages (after session.idle) as actual user activity. if (event.type === "message.updated") { - const messageInfo = event.properties?.info; + const messageInfo = event.properties?.info as MessageInfo | undefined; const messageId = messageInfo?.id; const isUserMessage = messageInfo?.role === 'user'; @@ -1129,9 +1216,18 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc } // Clear idle debounce for this session (allows fresh notifications) - const newSessionID = event.properties?.info?.id; + const sessionInfo = event.properties?.info as { id?: string } | undefined; + const newSessionID = sessionInfo?.id; if (newSessionID) { lastIdleNotificationTime.delete(newSessionID); + if (sessionCache.delete(newSessionID)) { + debugLog(`session.created: cleared session cache for ${newSessionID}`); + } + } + + const removedCacheEntries = cleanupExpiredSessionCache(); + if (removedCacheEntries > 0) { + debugLog(`session.created: cleaned ${removedCacheEntries} expired session cache entr${removedCacheEntries === 1 ? 'y' : 'ies'}`); } // Cleanup old debounce entries to prevent memory leaks (entries older than 1 hour) @@ -1176,16 +1272,25 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc // We set it early to prevent race conditions with concurrent events lastIdleNotificationTime.set(sessionID, now); - // Fetch session details for context-aware AI and sub-session filtering - let sessionData = null; + // Fetch session details for context-aware AI and sub-session filtering. + // Uses cache first to reduce API calls during repeated idle/error events. + let sessionData: Session | null = null; + let usedIdleSessionFallback = false; try { - const session = await client.session.get({ path: { id: sessionID } }); - sessionData = session?.data; + sessionData = await getSessionDataWithCache(sessionID, 'session.idle'); if (sessionData?.parentID) { - debugLog(`session.idle: skipped (sub-session ${sessionID})`); + lastIdleNotificationTime.delete(sessionID); + sessionCache.delete(sessionID); + debugLog(`session.idle: skipped (sub-session ${sessionID}); cleared debounce and cache entry`); return; } - } catch (e) {} + debugLog(`session.idle: session lookup passed for ${sessionID} (no parentID)`); + } catch (error) { + lastIdleNotificationTime.delete(sessionID); + sessionCache.delete(sessionID); + usedIdleSessionFallback = true; + debugLog(`session.idle: session lookup failed for ${sessionID}: ${getErrorMessage(error)}; using fallback notification flow with generic context`); + } // Build context for AI message generation (used when enableContextAwareAI is true) // Note: SDK's Project type doesn't have 'name' property, so we use derivedProjectName @@ -1212,14 +1317,17 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc showToast("✅ Agent has finished working", "success", 5000); // No await - instant display // Step 1b: Send desktop notification (only if not suppressed) - if (!suppressIdle) { - sendDesktopNotify('idle', 'Agent has finished working. Your code is ready for review.'); - } else { - debugLog('session.idle: desktop notification suppressed (terminal focused)'); - } + const idleDesktopMessage = usedIdleSessionFallback + ? 'Agent has finished working' + : 'Agent has finished working. Your code is ready for review.'; + if (!suppressIdle) { + sendDesktopNotify('idle', idleDesktopMessage); + } else { + debugLog('session.idle: desktop notification suppressed (terminal focused)'); + } - // Step 1c: Send webhook notification - sendWebhookNotify('idle', 'Agent has finished working. Your code is ready for review.', { sessionId: sessionID }); + // Step 1c: Send webhook notification + sendWebhookNotify('idle', idleDesktopMessage, { sessionId: sessionID }); // Step 2: Play sound (only if not suppressed) // Only play sound in sound-first, sound-only, or both mode @@ -1244,13 +1352,18 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc if (config.enableTTSReminder && config.notificationMode !== 'sound-only') { scheduleTTSReminder('idle', null, { fallbackSound: config.idleSound, - aiContext // Pass context for reminder message generation + aiContext: usedIdleSessionFallback ? {} : aiContext }); } // Step 5: If TTS-first or both mode, generate and speak immediate message if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') { - const ttsMessage = await getSmartMessage('idle', false, config.idleTTSMessages, aiContext); + const ttsMessage = await getSmartMessage( + 'idle', + false, + config.idleTTSMessages, + usedIdleSessionFallback ? {} : aiContext, + ); await tts.wakeMonitor(); await tts.forceVolume(); await tts.speak(ttsMessage, { @@ -1279,14 +1392,22 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc return; } - // Skip sub-sessions (child sessions spawned for parallel operations) + // Skip sub-sessions (child sessions spawned for parallel operations). + // Uses cache first to reduce API calls during repeated idle/error events. + let usedErrorSessionFallback = false; try { - const session = await client.session.get({ path: { id: sessionID } }); - if (session?.data?.parentID) { + const sessionData = await getSessionDataWithCache(sessionID, 'session.error'); + if (sessionData?.parentID) { + sessionCache.delete(sessionID); debugLog(`session.error: skipped (sub-session ${sessionID})`); return; } - } catch (e) {} + debugLog(`session.error: session lookup passed for ${sessionID} (no parentID)`); + } catch (error) { + sessionCache.delete(sessionID); + usedErrorSessionFallback = true; + debugLog(`session.error: session lookup failed for ${sessionID}: ${getErrorMessage(error)}; using fallback notification flow`); + } debugLog(`session.error: notifying for session ${sessionID}`); @@ -1298,14 +1419,17 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc showToast("❌ Agent encountered an error", "error", 8000); // No await - instant display // Step 1b: Send desktop notification (only if not suppressed) + const errorDesktopMessage = usedErrorSessionFallback + ? 'Agent encountered an error' + : 'The agent encountered an error and needs your attention.'; if (!suppressError) { - sendDesktopNotify('error', 'The agent encountered an error and needs your attention.'); + sendDesktopNotify('error', errorDesktopMessage); } else { debugLog('session.error: desktop notification suppressed (terminal focused)'); } // Step 1c: Send webhook notification - sendWebhookNotify('error', 'The agent encountered an error and needs your attention.', { sessionId: sessionID }); + sendWebhookNotify('error', errorDesktopMessage, { sessionId: sessionID }); // Step 2: Play sound (only if not suppressed) // Only play sound in sound-first, sound-only, or both mode @@ -1330,7 +1454,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc // Step 4: If TTS-first or both mode, generate and speak immediate message if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') { - const ttsMessage = await getErrorMessage(1, false); + const ttsMessage = await getErrorNotificationMessage(1, false); await tts.wakeMonitor(); await tts.forceVolume(); await tts.speak(ttsMessage, { @@ -1380,13 +1504,13 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc clearTimeout(permissionBatchTimeout); } - permissionBatchTimeout = setTimeout(async () => { - try { - await processPermissionBatch(); - } catch (e) { - debugLog(`processPermissionBatch error: ${e.message}`); - } - }, PERMISSION_BATCH_WINDOW_MS); + permissionBatchTimeout = setTimeout(async () => { + try { + await processPermissionBatch(); + } catch (error) { + debugLog(`processPermissionBatch error: ${getErrorMessage(error)}`); + } + }, PERMISSION_BATCH_WINDOW_MS); debugLog(`${event.type}: batch window reset (will process in ${PERMISSION_BATCH_WINDOW_MS}ms if no more arrive)`); } @@ -1437,8 +1561,8 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc questionBatchTimeout = setTimeout(async () => { try { await processQuestionBatch(); - } catch (e) { - debugLog(`processQuestionBatch error: ${e.message}`); + } catch (error) { + debugLog(`processQuestionBatch error: ${getErrorMessage(error)}`); } }, QUESTION_BATCH_WINDOW_MS); @@ -1503,8 +1627,8 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc cancelPendingReminder('question'); // Cancel question-specific reminder debugLog(`Question rejected: ${event.type} - cancelled question reminder`); } - } catch (e) { - debugLog(`event handler error: ${e.message}`); + } catch (error) { + debugLog(`event handler error: ${getErrorMessage(error)}`); } }, }; diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 0000000..8656ea1 --- /dev/null +++ b/src/types/config.ts @@ -0,0 +1,157 @@ +export type NotificationMode = 'sound-first' | 'tts-first' | 'both' | 'sound-only'; + +export type TTSEngine = 'openai' | 'elevenlabs' | 'edge' | 'sapi'; + +export type NotificationEventType = 'idle' | 'permission' | 'question' | 'error'; + +export type PluginEnabledValue = boolean | 'enabled' | 'disabled' | 'true' | 'false'; + +export type SapiPitch = 'x-low' | 'low' | 'medium' | 'high' | 'x-high' | string; + +export type SapiVolume = 'silent' | 'x-soft' | 'soft' | 'medium' | 'loud' | 'x-loud' | string; + +export type OpenAITtsFormat = 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm' | string; + +export interface AIPrompts { + idle: string; + permission: string; + question: string; + error: string; + idleReminder: string; + permissionReminder: string; + questionReminder: string; + errorReminder: string; + [key: string]: string; +} + +export interface SessionSummary { + files?: number; + additions?: number; + deletions?: number; +} + +export interface AIContext { + projectName?: string | null; + sessionTitle?: string; + sessionSummary?: SessionSummary; + count?: number; + type?: NotificationEventType | string; +} + +export interface PluginConfig { + _configVersion: string | null; + enabled: PluginEnabledValue; + + // Notification flow + notificationMode: NotificationMode; + enableTTSReminder: boolean; + enableIdleNotification: boolean; + enablePermissionNotification: boolean; + enableQuestionNotification: boolean; + enableErrorNotification: boolean; + enableIdleReminder: boolean; + enablePermissionReminder: boolean; + enableQuestionReminder: boolean; + enableErrorReminder: boolean; + ttsReminderDelaySeconds: number; + idleReminderDelaySeconds: number; + permissionReminderDelaySeconds: number; + questionReminderDelaySeconds: number; + errorReminderDelaySeconds: number; + enableFollowUpReminders: boolean; + maxFollowUpReminders: number; + reminderBackoffMultiplier: number; + + // TTS engine + ttsEngine: TTSEngine; + enableTTS: boolean; + elevenLabsApiKey?: string; + elevenLabsVoiceId: string; + elevenLabsModel: string; + elevenLabsStability: number; + elevenLabsSimilarity: number; + elevenLabsStyle: number; + edgeVoice: string; + edgePitch: string; + edgeRate: string; + edgeVolume?: string; + sapiVoice: string; + sapiRate: number; + sapiPitch: SapiPitch; + sapiVolume: SapiVolume; + + // OpenAI-compatible TTS + openaiTtsEndpoint: string; + openaiTtsApiKey: string; + openaiTtsModel: string; + openaiTtsVoice: string; + openaiTtsFormat: OpenAITtsFormat; + openaiTtsSpeed: number; + + // Message pools + idleTTSMessages: string[]; + permissionTTSMessages: string[]; + permissionTTSMessagesMultiple: string[]; + idleReminderTTSMessages: string[]; + permissionReminderTTSMessages: string[]; + permissionReminderTTSMessagesMultiple: string[]; + questionTTSMessages: string[]; + questionTTSMessagesMultiple: string[]; + questionReminderTTSMessages: string[]; + questionReminderTTSMessagesMultiple: string[]; + errorTTSMessages: string[]; + errorTTSMessagesMultiple: string[]; + errorReminderTTSMessages: string[]; + errorReminderTTSMessagesMultiple: string[]; + + // Batching + permissionBatchWindowMs: number; + questionBatchWindowMs: number; + + // AI-generated messages + enableAIMessages: boolean; + aiEndpoint: string; + aiModel: string; + aiApiKey: string; + aiTimeout: number; + aiFallbackToStatic: boolean; + enableContextAwareAI: boolean; + aiPrompts: AIPrompts; + + // Sound files + idleSound: string; + permissionSound: string; + questionSound: string; + errorSound: string; + + // System behavior + wakeMonitor: boolean; + forceVolume: boolean; + volumeThreshold: number; + idleThresholdSeconds: number; + enableToast: boolean; + enableSound: boolean; + + // Desktop notifications + enableDesktopNotification: boolean; + desktopNotificationTimeout: number; + showProjectInNotification: boolean; + suppressWhenFocused: boolean; + alwaysNotify: boolean; + + // Webhooks + enableWebhook: boolean; + webhookUrl: string; + webhookUsername: string; + webhookEvents: NotificationEventType[]; + webhookMentionOnPermission: boolean; + + // Theme + project sound routing + soundThemeDir: string; + randomizeSoundFromTheme: boolean; + perProjectSounds: boolean; + projectSoundSeed: number; + + // Logging + debugLog: boolean; +} diff --git a/src/types/detect-terminal.d.ts b/src/types/detect-terminal.d.ts new file mode 100644 index 0000000..a64cd27 --- /dev/null +++ b/src/types/detect-terminal.d.ts @@ -0,0 +1,7 @@ +declare module 'detect-terminal' { + export interface DetectTerminalOptions { + preferOuter?: boolean; + } + + export default function detectTerminal(options?: DetectTerminalOptions): string | null; +} diff --git a/src/types/events.ts b/src/types/events.ts new file mode 100644 index 0000000..ad6eeb9 --- /dev/null +++ b/src/types/events.ts @@ -0,0 +1,47 @@ +import type { AIContext, NotificationEventType } from './config.js'; + +export interface PendingReminder { + timeoutId: ReturnType; + scheduledAt: number; + followUpCount: number; + itemCount: number; + aiContext: AIContext; +} + +export interface QuestionBatchItem { + id: string; + questionCount: number; +} + +export interface PluginState { + pendingReminders: Map; + lastUserActivityTime: number; + lastSessionIdleTime: number; + activePermissionId: string | null | undefined; + activeQuestionId: string | null | undefined; + pendingPermissionBatch: string[]; + pendingQuestionBatch: QuestionBatchItem[]; + permissionBatchTimeout: ReturnType | null; + questionBatchTimeout: ReturnType | null; + lastIdleNotificationTime: Map; + seenUserMessageIds: Set; +} + +export interface SmartNotifyOptions { + soundFile?: string; + soundLoops?: number; + ttsMessage?: string | null; + fallbackSound?: string; + permissionCount?: number; + questionCount?: number; + errorCount?: number; + aiContext?: AIContext; +} + +export interface ScheduleReminderOptions { + fallbackSound?: string; + permissionCount?: number; + questionCount?: number; + errorCount?: number; + aiContext?: AIContext; +} diff --git a/src/types/linux.ts b/src/types/linux.ts new file mode 100644 index 0000000..e7b9744 --- /dev/null +++ b/src/types/linux.ts @@ -0,0 +1,39 @@ +import type { ShellRunner } from './opencode-sdk.js'; + +export type LinuxSessionType = 'x11' | 'wayland' | 'tty' | 'unknown'; + +export interface LinuxPlatformParams { + $?: ShellRunner; + debugLog?: (message: string) => void; +} + +export interface VolumeControlBackend { + getVolume(): Promise; + setVolume(volume: number): Promise; + unmute(): Promise; + isMuted(): Promise; +} + +export interface LinuxPlatformAPI { + isWayland(): boolean; + isX11(): boolean; + getSessionType(): LinuxSessionType; + + wakeMonitor(): Promise; + wakeMonitorX11(): Promise; + wakeMonitorGnomeDBus(): Promise; + + getCurrentVolume(): Promise; + setVolume(volume: number): Promise; + unmute(): Promise; + isMuted(): Promise; + forceVolume(): Promise; + forceVolumeIfNeeded(threshold?: number): Promise; + + pulse: VolumeControlBackend; + alsa: VolumeControlBackend; + + playAudioFile(filePath: string, loops?: number): Promise; + playAudioPulse(filePath: string): Promise; + playAudioAlsa(filePath: string): Promise; +} diff --git a/src/types/msedge-tts.d.ts b/src/types/msedge-tts.d.ts new file mode 100644 index 0000000..a10e95d --- /dev/null +++ b/src/types/msedge-tts.d.ts @@ -0,0 +1,18 @@ +declare module 'msedge-tts' { + export const OUTPUT_FORMAT: { + AUDIO_24KHZ_48KBITRATE_MONO_MP3: string; + }; + + export class MsEdgeTTS { + setMetadata(voice: string, format: string): Promise; + toFile( + directory: string, + text: string, + options?: { + pitch?: string; + rate?: string; + volume?: string; + }, + ): Promise<{ audioFilePath: string }>; + } +} diff --git a/src/types/notification.ts b/src/types/notification.ts new file mode 100644 index 0000000..7420b18 --- /dev/null +++ b/src/types/notification.ts @@ -0,0 +1,75 @@ +import type { NotificationEventType } from './config.js'; + +export interface DesktopNotifyOptions { + timeout?: number; + sound?: boolean; + icon?: string; + subtitle?: string; + urgency?: 'low' | 'normal' | 'critical'; + debugLog?: boolean; + projectName?: string; + count?: number; +} + +export interface NotificationResult { + success: boolean; + error?: string; + queued?: boolean; + statusCode?: number; +} + +export interface WebhookNotifyOptions { + projectName?: string; + sessionId?: string; + count?: number; + username?: string; + mention?: boolean; + useQueue?: boolean; + debugLog?: boolean; + timeout?: number; + avatarUrl?: string; + content?: string; +} + +export interface DiscordEmbedField { + name: string; + value: string; + inline?: boolean; +} + +export interface DiscordEmbed { + title: string; + description: string; + color: number; + timestamp: string; + footer?: { + text: string; + }; + fields?: DiscordEmbedField[]; +} + +export interface DiscordWebhookPayload { + username?: string; + avatar_url?: string; + content?: string; + embeds?: DiscordEmbed[]; +} + +export interface RateLimitState { + isRateLimited: boolean; + retryAfter: number; + retryTimestamp: number; +} + +export interface WebhookQueueItem { + url: string; + payload: DiscordWebhookPayload; + options?: { + retryCount?: number; + timeout?: number; + debugLog?: boolean; + eventType?: NotificationEventType; + [key: string]: unknown; + }; + queuedAt: number; +} diff --git a/src/types/opencode-sdk.ts b/src/types/opencode-sdk.ts new file mode 100644 index 0000000..20340e0 --- /dev/null +++ b/src/types/opencode-sdk.ts @@ -0,0 +1,216 @@ +import type { NotificationEventType } from './config.js'; + +export interface ShellResult { + stdout: Buffer | Uint8Array | string; + stderr: Buffer | Uint8Array | string; + exitCode: number; + text?: (encoding?: BufferEncoding) => string | Promise; + toString?: () => string; +} + +export interface ShellExecution extends Promise { + quiet(): this; + nothrow(): this; + timeout?(milliseconds: number): this; +} + +export interface ShellRunner { + (strings: TemplateStringsArray, ...values: Array): ShellExecution; +} + +export interface Project { + id?: string; + worktree?: string; + directory?: string; + vcsDir?: string; + vcs?: 'git' | string; + time?: { + created: number; + initialized?: number; + }; + [key: string]: unknown; +} + +export interface TUIClient { + showToast(input: { + body: { + message: string; + variant?: 'info' | 'success' | 'warning' | 'error'; + duration?: number; + title?: string; + }; + }): Promise; + [key: string]: unknown; +} + +export interface Session { + id: string; + projectID?: string; + directory?: string; + parentID?: string | null; + title?: string; + version?: string; + status?: string; + summary?: { + additions?: number; + deletions?: number; + files?: number; + [key: string]: unknown; + }; + time?: { + created?: number; + updated?: number; + compacting?: number; + }; + [key: string]: unknown; +} + +export interface SessionClient { + get(input: { path: { id: string } }): Promise<{ data?: Session }>; + [key: string]: unknown; +} + +export interface PermissionClient { + reply?(input: { + path?: { + id?: string; + sessionID?: string; + requestID?: string; + permissionID?: string; + }; + body?: { + reply?: 'once' | 'always' | 'reject' | string; + response?: string; + [key: string]: unknown; + }; + }): Promise; + [key: string]: unknown; +} + +export interface QuestionClient { + reply?(input: { + path?: { + id?: string; + requestID?: string; + sessionID?: string; + }; + body?: { + answers?: Array>; + [key: string]: unknown; + }; + }): Promise; + reject?(input: { + path?: { + id?: string; + requestID?: string; + sessionID?: string; + }; + body?: { + [key: string]: unknown; + }; + }): Promise; + [key: string]: unknown; +} + +export interface AppClient { + log?(input: { + body?: { + service?: string; + level?: string; + message?: string; + extra?: unknown; + [key: string]: unknown; + }; + service?: string; + level?: string; + message?: string; + extra?: unknown; + }): Promise; + [key: string]: unknown; +} + +export interface OpenCodeClient { + tui?: TUIClient; + session: SessionClient; + permission?: PermissionClient; + question?: QuestionClient; + app?: AppClient; + [key: string]: unknown; +} + +export interface PluginInitParams { + project: Project; + client: OpenCodeClient; + $: ShellRunner; + directory: string; + worktree: string; + serverUrl?: URL; +} + +export type EventType = + | 'server.instance.disposed' + | 'installation.updated' + | 'installation.update-available' + | 'lsp.client.diagnostics' + | 'lsp.updated' + | 'message.updated' + | 'message.removed' + | 'message.part.updated' + | 'message.part.delta' + | 'message.part.removed' + | 'permission.updated' + | 'permission.asked' + | 'permission.replied' + | 'question.asked' + | 'question.replied' + | 'question.rejected' + | 'session.status' + | 'session.idle' + | 'session.compacted' + | 'session.created' + | 'session.updated' + | 'session.deleted' + | 'session.diff' + | 'session.error' + | 'todo.updated' + | 'command.executed' + | 'file.edited' + | 'file.watcher.updated' + | 'vcs.branch.updated' + | 'tui.prompt.append' + | 'tui.command.execute' + | 'tui.toast.show' + | 'pty.created' + | 'pty.updated' + | 'pty.exited' + | 'pty.deleted' + | 'server.connected' + | `${NotificationEventType}.${string}` + | (string & {}); + +export interface EventProperties { + sessionID?: string; + messageID?: string; + partID?: string; + permissionID?: string; + requestID?: string; + id?: string; + info?: unknown; + error?: unknown; + response?: string; + reply?: string; + status?: unknown; + questions?: Array; + answers?: Array>; + [key: string]: unknown; +} + +export interface PluginEvent { + type: EventType; + properties?: EventProperties; +} + +export interface PluginHandlers { + event?: (input: { event: PluginEvent }) => Promise; + [key: string]: unknown; +} diff --git a/src/types/testing.ts b/src/types/testing.ts new file mode 100644 index 0000000..fee588d --- /dev/null +++ b/src/types/testing.ts @@ -0,0 +1,72 @@ +import type { + AppClient, + OpenCodeClient, + PermissionClient, + QuestionClient, + Session, + SessionClient, + ShellExecution, + ShellResult, + TUIClient, +} from './opencode-sdk.js'; + +export interface ShellCallRecord { + command: string; + timestamp: number; +} + +export interface MockShellResult extends Promise { + quiet(): this; + nothrow(): this; + timeout(milliseconds?: number): this; +} + +export interface MockShellRunner { + (strings: TemplateStringsArray, ...values: Array): ShellExecution | MockShellResult; + getCalls(): ShellCallRecord[]; + getLastCall(): ShellCallRecord | undefined; + getCallCount(): number; + reset(): void; + wasCalledWith(pattern: string | RegExp): boolean; +} + +export interface ToastBody { + message: string; + variant?: 'info' | 'success' | 'warning' | 'error'; + duration?: number; +} + +export interface ToastCall extends ToastBody { + timestamp: number; +} + +export interface MockSession extends Session { + status?: string; + parentID?: string | null; +} + +export interface MockClient extends OpenCodeClient { + tui: TUIClient & { + getToastCalls(): ToastCall[]; + resetToastCalls(): void; + }; + session: SessionClient & { + setMockSession(id: string, data: Partial): void; + clearMockSessions(): void; + }; + app?: AppClient; + permission?: PermissionClient; + question?: QuestionClient; +} + +export type ConsoleMethod = 'log' | 'warn' | 'error' | 'info' | 'debug'; + +export type ConsoleCaptureStore = Record; + +export interface ConsoleCapture { + start(): void; + stop(): void; + get(): ConsoleCaptureStore; + get(type: ConsoleMethod): unknown[][]; + clear(): void; +} diff --git a/src/types/tts.ts b/src/types/tts.ts new file mode 100644 index 0000000..51b79f7 --- /dev/null +++ b/src/types/tts.ts @@ -0,0 +1,32 @@ +import type { PluginConfig, TTSEngine } from './config.js'; +import type { OpenCodeClient, ShellRunner } from './opencode-sdk.js'; + +export interface SpeakOptions { + enableTTS?: boolean; + enableSound?: boolean; + ttsEngine?: TTSEngine; + fallbackSound?: string; + loops?: number; + [key: string]: unknown; +} + +export interface TTSAPI { + speak(message: string, options?: SpeakOptions): Promise; + announce(message: string, options?: SpeakOptions): Promise; + wakeMonitor(force?: boolean): Promise; + forceVolume(force?: boolean): Promise; + playAudioFile(filePath: string, loops?: number): Promise; + config: PluginConfig; +} + +export interface TTSFactoryParams { + $?: ShellRunner; + client?: OpenCodeClient; +} + +export interface ElevenLabsVoiceSettings { + stability?: number; + similarity_boost?: number; + style?: number; + use_speaker_boost?: boolean; +} diff --git a/util/ai-messages.js b/src/util/ai-messages.ts similarity index 67% rename from util/ai-messages.js rename to src/util/ai-messages.ts index 4f86fe8..5b87752 100644 --- a/util/ai-messages.js +++ b/src/util/ai-messages.ts @@ -1,24 +1,59 @@ /** * AI Message Generation Module - * + * * Generates dynamic notification messages using OpenAI-compatible AI endpoints. * Supports: Ollama, LM Studio, LocalAI, vLLM, llama.cpp, Jan.ai, etc. - * + * * Uses native fetch() - no external dependencies required. */ import fs from 'fs'; -import path from 'path'; import os from 'os'; +import path from 'path'; + +import type { AIContext, AIPrompts, PluginConfig } from '../types/config.js'; + import { getTTSConfig } from './tts.js'; +type PromptType = keyof AIPrompts | (string & {}); + +interface OpenAIChatCompletionResponse { + choices?: Array<{ + message?: { + content?: string; + }; + }>; +} + +interface OpenAIModelsResponse { + data?: Array<{ + id?: string; + }>; +} + +interface AIConnectionResult { + success: boolean; + message: string; + models?: string[]; +} + +const getErrorMessage = (error: unknown): string => { + const maybeError = error as { message?: unknown }; + return String(maybeError?.message ?? error); +}; + +const isAbortError = (error: unknown): boolean => { + const maybeError = error as { name?: unknown }; + return maybeError?.name === 'AbortError'; +}; + /** * Debug logging to file (no console output). * Logs are written to ~/.config/opencode/logs/smart-voice-notify-debug.log - * @param {string} message - Message to log - * @param {object} config - Config object with debugLog flag + * @param message - Message to log + * @param config - Config object with debugLog flag */ -const debugLog = (message, config) => { +const debugLog = (message: string, config: Partial | null | undefined): void => { if (!config?.debugLog) return; try { const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode'); @@ -29,34 +64,34 @@ const debugLog = (message, config) => { const logFile = path.join(logsDir, 'smart-voice-notify-debug.log'); const timestamp = new Date().toISOString(); fs.appendFileSync(logFile, `[${timestamp}] [ai-messages] ${message}\n`); - } catch (e) { + } catch { // Silently fail - logging should never break the plugin } }; /** * Generate a message using an OpenAI-compatible AI endpoint - * @param {string} promptType - The type of prompt ('idle', 'permission', 'question', 'idleReminder', 'permissionReminder', 'questionReminder') - * @param {object} context - Optional context about the notification (for future use) - * @returns {Promise} Generated message or null if failed + * @param promptType - The type of prompt ('idle', 'permission', 'question', 'idleReminder', 'permissionReminder', 'questionReminder') + * @param context - Optional context about the notification (for future use) + * @returns Generated message or null if failed */ -export async function generateAIMessage(promptType, context = {}) { +export async function generateAIMessage(promptType: PromptType, context: AIContext = {}): Promise { const config = getTTSConfig(); - + // Check if AI messages are enabled if (!config.enableAIMessages) { return null; } - + debugLog(`generateAIMessage: starting for promptType="${promptType}"`, config); - + // Get the prompt for this type let prompt = config.aiPrompts?.[promptType]; if (!prompt) { debugLog(`generateAIMessage: no prompt found for type "${promptType}"`, config); return null; } - + // Inject count context if multiple items if (context.count && context.count > 1) { // Use type-specific terminology @@ -69,26 +104,26 @@ export async function generateAIMessage(promptType, context = {}) { prompt = `${prompt} Important: There are ${context.count} ${itemType} (not just one) waiting for the user's attention. Mention the count in your message.`; debugLog(`generateAIMessage: injected count context (count=${context.count}, type=${context.type})`, config); } - + // Inject session/project context if context-aware AI is enabled if (config.enableContextAwareAI) { - debugLog(`generateAIMessage: context-aware AI is ENABLED`, config); - const contextParts = []; - + debugLog('generateAIMessage: context-aware AI is ENABLED', config); + const contextParts: string[] = []; + if (context.projectName) { contextParts.push(`Project: "${context.projectName}"`); debugLog(`generateAIMessage: context includes projectName="${context.projectName}"`, config); } - + if (context.sessionTitle) { contextParts.push(`Task: "${context.sessionTitle}"`); debugLog(`generateAIMessage: context includes sessionTitle="${context.sessionTitle}"`, config); } - + if (context.sessionSummary) { const { files, additions, deletions } = context.sessionSummary; if (files !== undefined || additions !== undefined || deletions !== undefined) { - const summaryParts = []; + const summaryParts: string[] = []; if (files !== undefined) summaryParts.push(`${files} file(s) modified`); if (additions !== undefined) summaryParts.push(`+${additions} lines`); if (deletions !== undefined) summaryParts.push(`-${deletions} lines`); @@ -96,36 +131,36 @@ export async function generateAIMessage(promptType, context = {}) { debugLog(`generateAIMessage: context includes sessionSummary (files=${files}, additions=${additions}, deletions=${deletions})`, config); } } - + if (contextParts.length > 0) { prompt = `${prompt}\n\nContext for this notification:\n${contextParts.join('\n')}\n\nIncorporate relevant context into your message to make it more specific and helpful (e.g., mention the project name or what was worked on).`; debugLog(`generateAIMessage: injected ${contextParts.length} context part(s) into prompt`, config); } else { - debugLog(`generateAIMessage: no context available to inject (projectName, sessionTitle, sessionSummary all empty)`, config); + debugLog('generateAIMessage: no context available to inject (projectName, sessionTitle, sessionSummary all empty)', config); } } else { debugLog(`generateAIMessage: context-aware AI is DISABLED (enableContextAwareAI=${config.enableContextAwareAI})`, config); } - + try { // Build headers - const headers = { 'Content-Type': 'application/json' }; + const headers: Record = { 'Content-Type': 'application/json' }; if (config.aiApiKey) { - headers['Authorization'] = `Bearer ${config.aiApiKey}`; + headers.Authorization = `Bearer ${config.aiApiKey}`; } - + // Build endpoint URL (ensure it ends with /chat/completions) let endpoint = config.aiEndpoint || 'http://localhost:11434/v1'; if (!endpoint.endsWith('/chat/completions')) { endpoint = endpoint.replace(/\/$/, '') + '/chat/completions'; } - + debugLog(`generateAIMessage: sending request to ${endpoint} (model=${config.aiModel || 'llama3'})`, config); - + // Create abort controller for timeout const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.aiTimeout || 15000); - + // Make the request const response = await fetch(endpoint, { method: 'POST', @@ -136,67 +171,71 @@ export async function generateAIMessage(promptType, context = {}) { messages: [ { role: 'system', - content: 'You are a helpful assistant that generates short notification messages. Output only the message text, nothing else. No quotes, no explanations.' + content: 'You are a helpful assistant that generates short notification messages. Output only the message text, nothing else. No quotes, no explanations.', }, { role: 'user', - content: prompt - } + content: prompt, + }, ], - max_tokens: 1000, // High value to accommodate thinking models (e.g., Gemini 2.5) that use internal reasoning tokens - temperature: 0.7 - }) + max_tokens: 1000, // High value to accommodate thinking models (e.g., Gemini 2.5) that use internal reasoning tokens + temperature: 0.7, + }), }); - + clearTimeout(timeout); - + if (!response.ok) { debugLog(`generateAIMessage: API request failed with status ${response.status}`, config); return null; } - - const data = await response.json(); - + + const data = (await response.json()) as OpenAIChatCompletionResponse; + // Extract the message content const message = data.choices?.[0]?.message?.content?.trim(); - + if (!message) { - debugLog(`generateAIMessage: API returned no message content`, config); + debugLog('generateAIMessage: API returned no message content', config); return null; } - + // Clean up the message (remove quotes if AI added them) - let cleanMessage = message.replace(/^["']|["']$/g, '').trim(); - + const cleanMessage = message.replace(/^["']|["']$/g, '').trim(); + // Validate message length (sanity check) if (cleanMessage.length < 5 || cleanMessage.length > 200) { debugLog(`generateAIMessage: message length invalid (${cleanMessage.length} chars), rejecting`, config); return null; } - + debugLog(`generateAIMessage: SUCCESS - generated message: "${cleanMessage.substring(0, 50)}${cleanMessage.length > 50 ? '...' : ''}"`, config); return cleanMessage; - } catch (error) { - debugLog(`generateAIMessage: ERROR - ${error.name === 'AbortError' ? 'Request timed out' : error.message}`, config); + debugLog(`generateAIMessage: ERROR - ${isAbortError(error) ? 'Request timed out' : getErrorMessage(error)}`, config); return null; } } /** * Get a smart message - tries AI first, falls back to static messages - * @param {string} eventType - 'idle', 'permission', 'question' - * @param {boolean} isReminder - Whether this is a reminder message - * @param {string[]} staticMessages - Array of static fallback messages - * @param {object} context - Optional context (e.g., { count: 3 } for batched notifications) - * @returns {Promise} The message to speak + * @param eventType - 'idle', 'permission', 'question' + * @param isReminder - Whether this is a reminder message + * @param staticMessages - Array of static fallback messages + * @param context - Optional context (e.g., { count: 3 } for batched notifications) + * @returns The message to speak */ -export async function getSmartMessage(eventType, isReminder, staticMessages, context = {}) { +export async function getSmartMessage( + eventType: string, + isReminder: boolean, + staticMessages: string[], + context: AIContext = {}, +): Promise { const config = getTTSConfig(); - + // Determine the prompt type - const promptType = isReminder ? `${eventType}Reminder` : eventType; - + const promptType = (isReminder ? `${eventType}Reminder` : eventType) as PromptType; + // Try AI generation if enabled if (config.enableAIMessages) { try { @@ -204,74 +243,74 @@ export async function getSmartMessage(eventType, isReminder, staticMessages, con if (aiMessage) { return aiMessage; } - } catch (error) { + } catch { // Silently fall through to fallback } - + // Check if fallback is disabled if (!config.aiFallbackToStatic) { // Return a generic message if fallback disabled and AI failed return 'Notification: Please check your screen.'; } } - + // Fallback to static messages if (!Array.isArray(staticMessages) || staticMessages.length === 0) { return 'Notification'; } - - return staticMessages[Math.floor(Math.random() * staticMessages.length)]; + + return staticMessages[Math.floor(Math.random() * staticMessages.length)]!; } /** * Test connectivity to the AI endpoint - * @returns {Promise<{success: boolean, message: string, model?: string}>} */ -export async function testAIConnection() { +export async function testAIConnection(): Promise { const config = getTTSConfig(); - + if (!config.enableAIMessages) { return { success: false, message: 'AI messages not enabled' }; } - + try { - const headers = { 'Content-Type': 'application/json' }; + const headers: Record = { 'Content-Type': 'application/json' }; if (config.aiApiKey) { - headers['Authorization'] = `Bearer ${config.aiApiKey}`; + headers.Authorization = `Bearer ${config.aiApiKey}`; } - + // Try to list models (simpler endpoint to test connectivity) let endpoint = config.aiEndpoint || 'http://localhost:11434/v1'; endpoint = endpoint.replace(/\/$/, '') + '/models'; - + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); - + const response = await fetch(endpoint, { method: 'GET', headers, - signal: controller.signal + signal: controller.signal, }); - + clearTimeout(timeout); - + if (response.ok) { - const data = await response.json(); - const models = data.data?.map(m => m.id) || []; + const data = (await response.json()) as OpenAIModelsResponse; + const models = (data.data ?? []) + .map((model) => model.id) + .filter((id): id is string => typeof id === 'string'); return { success: true, message: `Connected! Available models: ${models.slice(0, 3).join(', ')}${models.length > 3 ? '...' : ''}`, - models + models, }; - } else { - return { success: false, message: `HTTP ${response.status}: ${response.statusText}` }; } - + + return { success: false, message: `HTTP ${response.status}: ${response.statusText}` }; } catch (error) { - if (error.name === 'AbortError') { + if (isAbortError(error)) { return { success: false, message: 'Connection timed out' }; } - return { success: false, message: error.message }; + return { success: false, message: getErrorMessage(error) }; } } diff --git a/util/config.js b/src/util/config.ts similarity index 69% rename from util/config.js rename to src/util/config.ts index 4eceaaf..fbc8ae7 100644 --- a/util/config.js +++ b/src/util/config.ts @@ -1,15 +1,28 @@ import fs from 'fs'; -import path from 'path'; import os from 'os'; +import path from 'path'; import { fileURLToPath } from 'url'; +import type { NotificationEventType, PluginConfig } from '../types/config.js'; + +type JsonRecord = Record; + +const isPlainObject = (value: unknown): value is JsonRecord => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; + +const getErrorMessage = (error: unknown): string => { + const maybeError = error as { message?: unknown }; + return String(maybeError?.message); +}; + /** * Debug logging to file (no console output). * Logs are written to ~/.config/opencode/logs/smart-voice-notify-debug.log - * @param {string} message - Message to log - * @param {string} configDir - Config directory path + * @param message - Message to log + * @param configDir - Config directory path */ -const debugLogToFile = (message, configDir) => { +const debugLogToFile = (message: string, configDir: string): void => { try { const logsDir = path.join(configDir, 'logs'); if (!fs.existsSync(logsDir)) { @@ -18,7 +31,7 @@ const debugLogToFile = (message, configDir) => { const logFile = path.join(logsDir, 'smart-voice-notify-debug.log'); const timestamp = new Date().toISOString(); fs.appendFileSync(logFile, `[${timestamp}] [config] ${message}\n`); - } catch (e) { + } catch { // Silently fail - logging should never break the plugin } }; @@ -26,38 +39,38 @@ const debugLogToFile = (message, configDir) => { /** * Basic JSONC parser that strips single-line and multi-line comments, * and handles trailing commas (which Prettier often adds). - * @param {string} jsonc - * @returns {any} + * @param jsonc + * @returns parsed JSON object */ -export const parseJSONC = (jsonc) => { +export const parseJSONC = (jsonc: string): T => { // Step 1: Strip comments while preserving strings // This regex matches strings (handling escaped quotes) or comments // If it's a comment, we replace it with empty string - let stripped = jsonc.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => g ? "" : m); - + let stripped = jsonc.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g: string | undefined) => (g ? '' : m)); + // Step 2: Strip trailing commas (e.g. [1, 2,] or {"a":1,}) // This helps when formatters like Prettier are used stripped = stripped.replace(/,(\s*[\]}])/g, '$1'); - + // Step 3: Handle literal control characters that might be present // JSON.parse fails on literal control characters (U+0000 to U+001F). - // Some are allowed as whitespace (space, tab, newline, cr), but literal + // Some are allowed as whitespace (space, tab, newline, cr), but literal // tabs or newlines INSIDE strings are strictly forbidden. // We'll strip most of them, but preserve allowed whitespace outside strings. - // A safer approach for user-edited files is to remove characters that + // A safer approach for user-edited files is to remove characters that // definitely shouldn't be there. stripped = stripped.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); - return JSON.parse(stripped); + return JSON.parse(stripped) as T; }; /** * Helper to format JSON values for the template. - * @param {any} val - * @param {number} indent - * @returns {string} + * @param val + * @param indent + * @returns string */ -export const formatJSON = (val, indent = 0) => { +export const formatJSON = (val: unknown, indent = 0): string => { const json = JSON.stringify(val, null, 4); return indent > 0 ? json.replace(/\n/g, '\n' + ' '.repeat(indent)) : json; }; @@ -67,48 +80,50 @@ export const formatJSON = (val, indent = 0) => { * - For objects: recursively merge, adding new keys from defaults * - For arrays: user's array completely replaces default (no merge) * - For primitives: user's value takes precedence if it exists - * - * @param {object} defaults - The default configuration object - * @param {object} user - The user's existing configuration object - * @returns {object} Merged configuration with user values preserved + * + * @param defaults - The default configuration object + * @param user - The user's existing configuration object + * @returns Merged configuration with user values preserved */ -export const deepMerge = (defaults, user) => { +export const deepMerge = (defaults: T, user: unknown): T => { // If user value doesn't exist, use default if (user === undefined || user === null) { return defaults; } - + // If either is not an object (or is array), user value wins - if (typeof defaults !== 'object' || defaults === null || Array.isArray(defaults)) { - return user; + if (!isPlainObject(defaults)) { + return user as T; } - if (typeof user !== 'object' || user === null || Array.isArray(user)) { - return user; + if (!isPlainObject(user)) { + return user as T; } - + // Both are objects - merge them - const result = { ...user }; - + const result: JsonRecord = { ...user }; + for (const key of Object.keys(defaults)) { if (!(key in user)) { // Key doesn't exist in user config - add it from defaults result[key] = defaults[key]; - } else if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) { + } else if (isPlainObject(defaults[key])) { // Both have this key and it's an object - recurse result[key] = deepMerge(defaults[key], user[key]); } // else: user has this key and it's not an object to merge - keep user's value } - - return result; + + return result as T; }; +const defaultWebhookEvents: NotificationEventType[] = ['idle', 'permission', 'error', 'question']; + /** * Get the default configuration object. * This is the source of truth for all default values. - * @returns {object} Default configuration object + * @returns Default configuration object */ -export const getDefaultConfigObject = () => ({ +export const getDefaultConfigObject = (): PluginConfig => ({ _configVersion: null, // Will be set by caller enabled: true, @@ -150,105 +165,105 @@ export const getDefaultConfigObject = () => ({ openaiTtsFormat: 'mp3', openaiTtsSpeed: 1.0, idleTTSMessages: [ - "All done! Your task has been completed successfully.", - "Hey there! I finished working on your request.", - "Task complete! Ready for your review whenever you are.", - "Good news! Everything is done and ready for you.", - "Finished! Let me know if you need anything else." + 'All done! Your task has been completed successfully.', + 'Hey there! I finished working on your request.', + 'Task complete! Ready for your review whenever you are.', + 'Good news! Everything is done and ready for you.', + 'Finished! Let me know if you need anything else.', ], permissionTTSMessages: [ - "Attention please! I need your permission to continue.", - "Hey! Quick approval needed to proceed with the task.", - "Heads up! There is a permission request waiting for you.", - "Excuse me! I need your authorization before I can continue.", - "Permission required! Please review and approve when ready." + 'Attention please! I need your permission to continue.', + 'Hey! Quick approval needed to proceed with the task.', + 'Heads up! There is a permission request waiting for you.', + 'Excuse me! I need your authorization before I can continue.', + 'Permission required! Please review and approve when ready.', ], permissionTTSMessagesMultiple: [ - "Attention please! There are {count} permission requests waiting for your approval.", - "Hey! {count} permissions need your approval to continue.", - "Heads up! You have {count} pending permission requests.", - "Excuse me! I need your authorization for {count} different actions.", - "{count} permissions required! Please review and approve when ready." + 'Attention please! There are {count} permission requests waiting for your approval.', + 'Hey! {count} permissions need your approval to continue.', + 'Heads up! You have {count} pending permission requests.', + 'Excuse me! I need your authorization for {count} different actions.', + '{count} permissions required! Please review and approve when ready.', ], idleReminderTTSMessages: [ - "Hey, are you still there? Your task has been waiting for review.", - "Just a gentle reminder - I finished your request a while ago!", - "Hello? I completed your task. Please take a look when you can.", - "Still waiting for you! The work is done and ready for review.", - "Knock knock! Your completed task is patiently waiting for you." + 'Hey, are you still there? Your task has been waiting for review.', + 'Just a gentle reminder - I finished your request a while ago!', + 'Hello? I completed your task. Please take a look when you can.', + 'Still waiting for you! The work is done and ready for review.', + 'Knock knock! Your completed task is patiently waiting for you.', ], permissionReminderTTSMessages: [ - "Hey! I still need your permission to continue. Please respond!", - "Reminder: There is a pending permission request. I cannot proceed without you.", - "Hello? I am waiting for your approval. This is getting urgent!", - "Please check your screen! I really need your permission to move forward.", - "Still waiting for authorization! The task is on hold until you respond." + 'Hey! I still need your permission to continue. Please respond!', + 'Reminder: There is a pending permission request. I cannot proceed without you.', + 'Hello? I am waiting for your approval. This is getting urgent!', + 'Please check your screen! I really need your permission to move forward.', + 'Still waiting for authorization! The task is on hold until you respond.', ], permissionReminderTTSMessagesMultiple: [ - "Hey! I still need your approval for {count} permissions. Please respond!", - "Reminder: There are {count} pending permission requests. I cannot proceed without you.", - "Hello? I am waiting for your approval on {count} items. This is getting urgent!", - "Please check your screen! {count} permissions are waiting for your response.", - "Still waiting for authorization on {count} requests! The task is on hold." + 'Hey! I still need your approval for {count} permissions. Please respond!', + 'Reminder: There are {count} pending permission requests. I cannot proceed without you.', + 'Hello? I am waiting for your approval on {count} items. This is getting urgent!', + 'Please check your screen! {count} permissions are waiting for your response.', + 'Still waiting for authorization on {count} requests! The task is on hold.', ], permissionBatchWindowMs: 800, questionTTSMessages: [ - "Hey! I have a question for you. Please check your screen.", - "Attention! I need your input to continue.", - "Quick question! Please take a look when you have a moment.", - "I need some clarification. Could you please respond?", - "Question time! Your input is needed to proceed." + 'Hey! I have a question for you. Please check your screen.', + 'Attention! I need your input to continue.', + 'Quick question! Please take a look when you have a moment.', + 'I need some clarification. Could you please respond?', + 'Question time! Your input is needed to proceed.', ], questionTTSMessagesMultiple: [ - "Hey! I have {count} questions for you. Please check your screen.", - "Attention! I need your input on {count} items to continue.", - "{count} questions need your attention. Please take a look!", - "I need some clarifications. There are {count} questions waiting for you.", - "Question time! {count} questions need your response to proceed." + 'Hey! I have {count} questions for you. Please check your screen.', + 'Attention! I need your input on {count} items to continue.', + '{count} questions need your attention. Please take a look!', + 'I need some clarifications. There are {count} questions waiting for you.', + 'Question time! {count} questions need your response to proceed.', ], questionReminderTTSMessages: [ - "Hey! I am still waiting for your answer. Please check the questions!", - "Reminder: There is a question waiting for your response.", - "Hello? I need your input to continue. Please respond when you can.", - "Still waiting for your answer! The task is on hold.", - "Your input is needed! Please check the pending question." + 'Hey! I am still waiting for your answer. Please check the questions!', + 'Reminder: There is a question waiting for your response.', + 'Hello? I need your input to continue. Please respond when you can.', + 'Still waiting for your answer! The task is on hold.', + 'Your input is needed! Please check the pending question.', ], questionReminderTTSMessagesMultiple: [ - "Hey! I am still waiting for answers to {count} questions. Please respond!", - "Reminder: There are {count} questions waiting for your response.", - "Hello? I need your input on {count} items. Please respond when you can.", - "Still waiting for your answers on {count} questions! The task is on hold.", - "Your input is needed! {count} questions are pending your response." + 'Hey! I am still waiting for answers to {count} questions. Please respond!', + 'Reminder: There are {count} questions waiting for your response.', + 'Hello? I need your input on {count} items. Please respond when you can.', + 'Still waiting for your answers on {count} questions! The task is on hold.', + 'Your input is needed! {count} questions are pending your response.', ], questionReminderDelaySeconds: 25, questionBatchWindowMs: 800, errorTTSMessages: [ - "Oops! Something went wrong. Please check for errors.", - "Alert! The agent encountered an error and needs your attention.", - "Error detected! Please review the issue when you can.", - "Houston, we have a problem! An error occurred during the task.", - "Heads up! There was an error that requires your attention." + 'Oops! Something went wrong. Please check for errors.', + 'Alert! The agent encountered an error and needs your attention.', + 'Error detected! Please review the issue when you can.', + 'Houston, we have a problem! An error occurred during the task.', + 'Heads up! There was an error that requires your attention.', ], errorTTSMessagesMultiple: [ - "Oops! There are {count} errors that need your attention.", - "Alert! The agent encountered {count} errors. Please review.", - "{count} errors detected! Please check when you can.", - "Houston, we have {count} problems! Multiple errors occurred.", - "Heads up! {count} errors require your attention." + 'Oops! There are {count} errors that need your attention.', + 'Alert! The agent encountered {count} errors. Please review.', + '{count} errors detected! Please check when you can.', + 'Houston, we have {count} problems! Multiple errors occurred.', + 'Heads up! {count} errors require your attention.', ], errorReminderTTSMessages: [ "Hey! There's still an error waiting for your attention.", "Reminder: An error occurred and hasn't been addressed yet.", - "The agent is stuck! Please check the error when you can.", - "Still waiting! That error needs your attention.", - "Don't forget! There's an unresolved error in your session." + 'The agent is stuck! Please check the error when you can.', + 'Still waiting! That error needs your attention.', + "Don't forget! There's an unresolved error in your session.", ], errorReminderTTSMessagesMultiple: [ - "Hey! There are still {count} errors waiting for your attention.", + 'Hey! There are still {count} errors waiting for your attention.', "Reminder: {count} errors occurred and haven't been addressed yet.", - "The agent is stuck! Please check the {count} errors when you can.", - "Still waiting! {count} errors need your attention.", - "Don't forget! There are {count} unresolved errors in your session." + 'The agent is stuck! Please check the {count} errors when you can.', + 'Still waiting! {count} errors need your attention.', + "Don't forget! There are {count} unresolved errors in your session.", ], errorReminderDelaySeconds: 20, enableAIMessages: false, @@ -259,14 +274,14 @@ export const getDefaultConfigObject = () => ({ aiFallbackToStatic: true, enableContextAwareAI: false, aiPrompts: { - idle: "Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.", - permission: "Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.", - question: "Generate a single brief, polite notification sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.", - error: "Generate a single brief, concerned but calm notification sentence (max 15 words) saying an error occurred and needs attention. Output only the message, no quotes.", - idleReminder: "Generate a single brief, gentle reminder sentence (max 15 words) that a completed task is waiting for review. Be slightly more insistent. Output only the message, no quotes.", - permissionReminder: "Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey importance. Output only the message, no quotes.", - questionReminder: "Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes.", - errorReminder: "Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Convey urgency. Output only the message, no quotes." + idle: 'Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.', + permission: 'Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.', + question: 'Generate a single brief, polite notification sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.', + error: 'Generate a single brief, concerned but calm notification sentence (max 15 words) saying an error occurred and needs attention. Output only the message, no quotes.', + idleReminder: 'Generate a single brief, gentle reminder sentence (max 15 words) that a completed task is waiting for review. Be slightly more insistent. Output only the message, no quotes.', + permissionReminder: 'Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey importance. Output only the message, no quotes.', + questionReminder: 'Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes.', + errorReminder: 'Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Convey urgency. Output only the message, no quotes.', }, idleSound: 'assets/Soft-high-tech-notification-sound-effect.mp3', permissionSound: 'assets/Machine-alert-beep-sound-effect.mp3', @@ -280,49 +295,51 @@ export const getDefaultConfigObject = () => ({ enableDesktopNotification: true, desktopNotificationTimeout: 5, showProjectInNotification: true, - suppressWhenFocused: true, + suppressWhenFocused: false, alwaysNotify: false, enableWebhook: false, - webhookUrl: "", - webhookUsername: "OpenCode Notify", - webhookEvents: ["idle", "permission", "error", "question"], + webhookUrl: '', + webhookUsername: 'OpenCode Notify', + webhookEvents: [...defaultWebhookEvents], webhookMentionOnPermission: false, - soundThemeDir: "", + soundThemeDir: '', randomizeSoundFromTheme: true, perProjectSounds: false, projectSoundSeed: 0, idleThresholdSeconds: 60, - debugLog: false + debugLog: false, }); /** * Find new fields that exist in defaults but not in user config. * Used for logging what was added during migration. - * @param {object} defaults - * @param {object} user - * @param {string} prefix - * @returns {string[]} Array of field paths that were added + * @param defaults + * @param user + * @param prefix + * @returns Array of field paths that were added */ -export const findNewFields = (defaults, user, prefix = '') => { +export const findNewFields = (defaults: unknown, user: unknown, prefix = ''): string[] => { + + const newFields: string[] = []; - const newFields = []; - - if (typeof defaults !== 'object' || defaults === null || Array.isArray(defaults)) { + if (!isPlainObject(defaults)) { return newFields; } - + + const userRecord = user as JsonRecord; + for (const key of Object.keys(defaults)) { const fieldPath = prefix ? `${prefix}.${key}` : key; - - if (!(key in user)) { + + if (!(key in userRecord)) { newFields.push(fieldPath); - } else if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) { - if (typeof user[key] === 'object' && user[key] !== null && !Array.isArray(user[key])) { - newFields.push(...findNewFields(defaults[key], user[key], fieldPath)); + } else if (isPlainObject(defaults[key])) { + if (isPlainObject(userRecord[key])) { + newFields.push(...findNewFields(defaults[key], userRecord[key], fieldPath)); } } } - + return newFields; }; @@ -330,19 +347,33 @@ export const findNewFields = (defaults, user, prefix = '') => { * Get the directory where this plugin is installed. * Used to find bundled assets like example.config.jsonc */ -const getPluginDir = () => { +const getPluginDir = (): string => { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - return path.dirname(__dirname); // Go up from util/ to plugin root + + // Support running from src/util, dist/util, or legacy util paths. + const candidates = [ + path.resolve(__dirname, '..', '..'), + path.resolve(__dirname, '..'), + path.resolve(__dirname), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, 'package.json'))) { + return candidate; + } + } + + return path.resolve(__dirname, '..'); }; /** * Generate a comprehensive default configuration file content. * This provides users with ALL available options fully documented. - * @param {object} overrides - Existing configuration to preserve - * @param {string} version - Current version to set in config + * @param overrides - Existing configuration to preserve + * @param version - Current version to set in config */ -const generateDefaultConfig = (overrides = {}, version = '1.0.0') => { +const generateDefaultConfig = (overrides: Partial = {}, version = '1.0.0'): string => { return `{ // ============================================================ // OpenCode Smart Voice Notify - Configuration @@ -420,7 +451,7 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => { // ============================================================ // 'openai' - OpenAI-compatible TTS (Self-hosted/Cloud, e.g. Kokoro, LocalAI) // 'elevenlabs' - Best quality, anime-like voices (requires API key, free tier: 10k chars/month) - // 'edge' - Good quality neural voices (free, requires: pip install edge-tts) + // 'edge' - Good quality neural voices (Python edge-tts CLI RECOMMENDED, with msedge-tts npm fallback) // 'sapi' - Windows built-in voices (free, offline, robotic) "ttsEngine": "${overrides.ttsEngine || 'elevenlabs'}", @@ -456,9 +487,10 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => { "elevenLabsStyle": ${overrides.elevenLabsStyle !== undefined ? overrides.elevenLabsStyle : 0.5}, // Style exaggeration (higher = more expressive) // ============================================================ - // EDGE TTS SETTINGS (Free Neural Voices - Default Engine) + // EDGE TTS SETTINGS (Free Neural Voices) // ============================================================ - // Requires: pip install edge-tts + // Uses Python edge-tts CLI (RECOMMENDED, pip install edge-tts) with automatic + // fallback to msedge-tts npm package if Python is not available. // Voice options (run 'edge-tts --list-voices' to see all): // 'en-US-AnaNeural' - Young, cute, cartoon-like (RECOMMENDED) @@ -534,30 +566,30 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => { // Messages when agent finishes work (task completion) "idleTTSMessages": ${formatJSON(overrides.idleTTSMessages || [ - "All done! Your task has been completed successfully.", - "Hey there! I finished working on your request.", - "Task complete! Ready for your review whenever you are.", - "Good news! Everything is done and ready for you.", - "Finished! Let me know if you need anything else." + 'All done! Your task has been completed successfully.', + 'Hey there! I finished working on your request.', + 'Task complete! Ready for your review whenever you are.', + 'Good news! Everything is done and ready for you.', + 'Finished! Let me know if you need anything else.', ], 4)}, // Messages for permission requests "permissionTTSMessages": ${formatJSON(overrides.permissionTTSMessages || [ - "Attention please! I need your permission to continue.", - "Hey! Quick approval needed to proceed with the task.", - "Heads up! There is a permission request waiting for you.", - "Excuse me! I need your authorization before I can continue.", - "Permission required! Please review and approve when ready." + 'Attention please! I need your permission to continue.', + 'Hey! Quick approval needed to proceed with the task.', + 'Heads up! There is a permission request waiting for you.', + 'Excuse me! I need your authorization before I can continue.', + 'Permission required! Please review and approve when ready.', ], 4)}, // Messages for MULTIPLE permission requests (use {count} placeholder) // Used when several permissions arrive simultaneously "permissionTTSMessagesMultiple": ${formatJSON(overrides.permissionTTSMessagesMultiple || [ - "Attention please! There are {count} permission requests waiting for your approval.", - "Hey! {count} permissions need your approval to continue.", - "Heads up! You have {count} pending permission requests.", - "Excuse me! I need your authorization for {count} different actions.", - "{count} permissions required! Please review and approve when ready." + 'Attention please! There are {count} permission requests waiting for your approval.', + 'Hey! {count} permissions need your approval to continue.', + 'Heads up! You have {count} pending permission requests.', + 'Excuse me! I need your authorization for {count} different actions.', + '{count} permissions required! Please review and approve when ready.', ], 4)}, // ============================================================ @@ -567,29 +599,29 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => { // Reminder messages when agent finished but user hasn't responded "idleReminderTTSMessages": ${formatJSON(overrides.idleReminderTTSMessages || [ - "Hey, are you still there? Your task has been waiting for review.", - "Just a gentle reminder - I finished your request a while ago!", - "Hello? I completed your task. Please take a look when you can.", - "Still waiting for you! The work is done and ready for review.", - "Knock knock! Your completed task is patiently waiting for you." + 'Hey, are you still there? Your task has been waiting for review.', + 'Just a gentle reminder - I finished your request a while ago!', + 'Hello? I completed your task. Please take a look when you can.', + 'Still waiting for you! The work is done and ready for review.', + 'Knock knock! Your completed task is patiently waiting for you.', ], 4)}, // Reminder messages when permission still needed "permissionReminderTTSMessages": ${formatJSON(overrides.permissionReminderTTSMessages || [ - "Hey! I still need your permission to continue. Please respond!", - "Reminder: There is a pending permission request. I cannot proceed without you.", - "Hello? I am waiting for your approval. This is getting urgent!", - "Please check your screen! I really need your permission to move forward.", - "Still waiting for authorization! The task is on hold until you respond." + 'Hey! I still need your permission to continue. Please respond!', + 'Reminder: There is a pending permission request. I cannot proceed without you.', + 'Hello? I am waiting for your approval. This is getting urgent!', + 'Please check your screen! I really need your permission to move forward.', + 'Still waiting for authorization! The task is on hold until you respond.', ], 4)}, // Reminder messages for MULTIPLE permissions (use {count} placeholder) "permissionReminderTTSMessagesMultiple": ${formatJSON(overrides.permissionReminderTTSMessagesMultiple || [ - "Hey! I still need your approval for {count} permissions. Please respond!", - "Reminder: There are {count} pending permission requests. I cannot proceed without you.", - "Hello? I am waiting for your approval on {count} items. This is getting urgent!", - "Please check your screen! {count} permissions are waiting for your response.", - "Still waiting for authorization on {count} requests! The task is on hold." + 'Hey! I still need your approval for {count} permissions. Please respond!', + 'Reminder: There are {count} pending permission requests. I cannot proceed without you.', + 'Hello? I am waiting for your approval on {count} items. This is getting urgent!', + 'Please check your screen! {count} permissions are waiting for your response.', + 'Still waiting for authorization on {count} requests! The task is on hold.', ], 4)}, // ============================================================ @@ -610,38 +642,38 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => { // Messages when agent asks user a question "questionTTSMessages": ${formatJSON(overrides.questionTTSMessages || [ - "Hey! I have a question for you. Please check your screen.", - "Attention! I need your input to continue.", - "Quick question! Please take a look when you have a moment.", - "I need some clarification. Could you please respond?", - "Question time! Your input is needed to proceed." + 'Hey! I have a question for you. Please check your screen.', + 'Attention! I need your input to continue.', + 'Quick question! Please take a look when you have a moment.', + 'I need some clarification. Could you please respond?', + 'Question time! Your input is needed to proceed.', ], 4)}, // Messages for MULTIPLE questions (use {count} placeholder) "questionTTSMessagesMultiple": ${formatJSON(overrides.questionTTSMessagesMultiple || [ - "Hey! I have {count} questions for you. Please check your screen.", - "Attention! I need your input on {count} items to continue.", - "{count} questions need your attention. Please take a look!", - "I need some clarifications. There are {count} questions waiting for you.", - "Question time! {count} questions need your response to proceed." + 'Hey! I have {count} questions for you. Please check your screen.', + 'Attention! I need your input on {count} items to continue.', + '{count} questions need your attention. Please take a look!', + 'I need some clarifications. There are {count} questions waiting for you.', + 'Question time! {count} questions need your response to proceed.', ], 4)}, // Reminder messages for questions (more urgent - used after delay) "questionReminderTTSMessages": ${formatJSON(overrides.questionReminderTTSMessages || [ - "Hey! I am still waiting for your answer. Please check the questions!", - "Reminder: There is a question waiting for your response.", - "Hello? I need your input to continue. Please respond when you can.", - "Still waiting for your answer! The task is on hold.", - "Your input is needed! Please check the pending question." + 'Hey! I am still waiting for your answer. Please check the questions!', + 'Reminder: There is a question waiting for your response.', + 'Hello? I need your input to continue. Please respond when you can.', + 'Still waiting for your answer! The task is on hold.', + 'Your input is needed! Please check the pending question.', ], 4)}, // Reminder messages for MULTIPLE questions (use {count} placeholder) "questionReminderTTSMessagesMultiple": ${formatJSON(overrides.questionReminderTTSMessagesMultiple || [ - "Hey! I am still waiting for answers to {count} questions. Please respond!", - "Reminder: There are {count} questions waiting for your response.", - "Hello? I need your input on {count} items. Please respond when you can.", - "Still waiting for your answers on {count} questions! The task is on hold.", - "Your input is needed! {count} questions are pending your response." + 'Hey! I am still waiting for answers to {count} questions. Please respond!', + 'Reminder: There are {count} questions waiting for your response.', + 'Hello? I need your input on {count} items. Please respond when you can.', + 'Still waiting for your answers on {count} questions! The task is on hold.', + 'Your input is needed! {count} questions are pending your response.', ], 4)}, // Delay (in seconds) before question reminder fires @@ -658,38 +690,38 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => { // Messages when agent encounters an error "errorTTSMessages": ${formatJSON(overrides.errorTTSMessages || [ - "Oops! Something went wrong. Please check for errors.", - "Alert! The agent encountered an error and needs your attention.", - "Error detected! Please review the issue when you can.", - "Houston, we have a problem! An error occurred during the task.", - "Heads up! There was an error that requires your attention." + 'Oops! Something went wrong. Please check for errors.', + 'Alert! The agent encountered an error and needs your attention.', + 'Error detected! Please review the issue when you can.', + 'Houston, we have a problem! An error occurred during the task.', + 'Heads up! There was an error that requires your attention.', ], 4)}, // Messages for MULTIPLE errors (use {count} placeholder) "errorTTSMessagesMultiple": ${formatJSON(overrides.errorTTSMessagesMultiple || [ - "Oops! There are {count} errors that need your attention.", - "Alert! The agent encountered {count} errors. Please review.", - "{count} errors detected! Please check when you can.", - "Houston, we have {count} problems! Multiple errors occurred.", - "Heads up! {count} errors require your attention." + 'Oops! There are {count} errors that need your attention.', + 'Alert! The agent encountered {count} errors. Please review.', + '{count} errors detected! Please check when you can.', + 'Houston, we have {count} problems! Multiple errors occurred.', + 'Heads up! {count} errors require your attention.', ], 4)}, // Reminder messages for errors (more urgent - used after delay) "errorReminderTTSMessages": ${formatJSON(overrides.errorReminderTTSMessages || [ "Hey! There's still an error waiting for your attention.", "Reminder: An error occurred and hasn't been addressed yet.", - "The agent is stuck! Please check the error when you can.", - "Still waiting! That error needs your attention.", - "Don't forget! There's an unresolved error in your session." + 'The agent is stuck! Please check the error when you can.', + 'Still waiting! That error needs your attention.', + "Don't forget! There's an unresolved error in your session.", ], 4)}, // Reminder messages for MULTIPLE errors (use {count} placeholder) "errorReminderTTSMessagesMultiple": ${formatJSON(overrides.errorReminderTTSMessagesMultiple || [ - "Hey! There are still {count} errors waiting for your attention.", + 'Hey! There are still {count} errors waiting for your attention.', "Reminder: {count} errors occurred and haven't been addressed yet.", - "The agent is stuck! Please check the {count} errors when you can.", - "Still waiting! {count} errors need your attention.", - "Don't forget! There are {count} unresolved errors in your session." + 'The agent is stuck! Please check the {count} errors when you can.', + 'Still waiting! {count} errors need your attention.', + "Don't forget! There are {count} unresolved errors in your session.", ], 4)}, // Delay (in seconds) before error reminder fires (shorter than idle for urgency) @@ -743,14 +775,14 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => { // The AI will generate a short message based on these prompts // Keep prompts concise - they're sent with each notification "aiPrompts": ${formatJSON(overrides.aiPrompts || { - "idle": "Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.", - "permission": "Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.", - "question": "Generate a single brief, polite notification sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.", - "error": "Generate a single brief, concerned but calm notification sentence (max 15 words) saying an error occurred and needs attention. Output only the message, no quotes.", - "idleReminder": "Generate a single brief, gentle reminder sentence (max 15 words) that a completed task is waiting for review. Be slightly more insistent. Output only the message, no quotes.", - "permissionReminder": "Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey importance. Output only the message, no quotes.", - "questionReminder": "Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes.", - "errorReminder": "Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Convey urgency. Output only the message, no quotes." + idle: 'Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.', + permission: 'Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.', + question: 'Generate a single brief, polite notification sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.', + error: 'Generate a single brief, concerned but calm notification sentence (max 15 words) saying an error occurred and needs attention. Output only the message, no quotes.', + idleReminder: 'Generate a single brief, gentle reminder sentence (max 15 words) that a completed task is waiting for review. Be slightly more insistent. Output only the message, no quotes.', + permissionReminder: 'Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey importance. Output only the message, no quotes.', + questionReminder: 'Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes.', + errorReminder: 'Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Convey urgency. Output only the message, no quotes.', }, 4)}, // ============================================================ @@ -810,21 +842,11 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => { // ============================================================ // FOCUS DETECTION SETTINGS // ============================================================ - // Suppress notifications when you're actively looking at the terminal. - // This prevents notifications from interrupting you when you're already - // paying attention to the OpenCode terminal. - // - // PLATFORM SUPPORT: - // macOS: Full support - Uses AppleScript to detect frontmost application - // Windows: Not supported - No reliable API available - // Linux: Not supported - Varies by desktop environment - // - // When focus detection is not supported on your platform, notifications - // will always be sent (fail-open behavior). - - // Suppress sound and desktop notifications when terminal is focused - // TTS reminders are still allowed (user might step away after task completes) - "suppressWhenFocused": ${overrides.suppressWhenFocused !== undefined ? overrides.suppressWhenFocused : true}, + // Suppress sound/desktop notifications when terminal window is focused. + // Cross-platform: Windows, macOS, and Linux (X11 via xdotool/xprop, Wayland via gdbus). + // Default: false (notifications always play regardless of focus) + // Set to true to avoid notification spam when actively working in terminal + "suppressWhenFocused": ${overrides.suppressWhenFocused !== undefined ? overrides.suppressWhenFocused : false}, // Override focus detection: always send notifications even when terminal is focused // Set to true to disable focus-based suppression entirely @@ -847,7 +869,7 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => { // Events that should trigger a webhook notification // Options: "idle", "permission", "error", "question" - "webhookEvents": ${formatJSON(overrides.webhookEvents || ["idle", "permission", "error", "question"], 4)}, + "webhookEvents": ${formatJSON(overrides.webhookEvents || defaultWebhookEvents, 4)}, // Mention @everyone on permission requests (Discord only) "webhookMentionOnPermission": ${overrides.webhookMentionOnPermission !== undefined ? overrides.webhookMentionOnPermission : false}, @@ -900,9 +922,9 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => { /** * Copy bundled assets (sound files) to the OpenCode config directory. - * @param {string} configDir - The OpenCode config directory path + * @param configDir - The OpenCode config directory path */ -const copyBundledAssets = (configDir) => { +const copyBundledAssets = (configDir: string): void => { try { const pluginDir = getPluginDir(); const sourceAssetsDir = path.join(pluginDir, 'assets'); @@ -929,7 +951,7 @@ const copyBundledAssets = (configDir) => { fs.copyFileSync(sourcePath, targetPath); } } - } catch (error) { + } catch { // Silently fail - assets are optional } }; @@ -938,20 +960,20 @@ const copyBundledAssets = (configDir) => { * Loads a configuration file from the OpenCode config directory. * If the file doesn't exist, creates a default config file with full documentation. * If the file exists, performs smart merging to add new fields without overwriting user values. - * + * * IMPORTANT: User values are NEVER overwritten. Only new fields from plugin updates are added. - * - * @param {string} name - Name of the config file (without .jsonc extension) - * @param {object} defaults - Default values if file doesn't exist or is invalid - * @returns {object} + * + * @param name - Name of the config file (without .jsonc extension) + * @param defaults - Default values if file doesn't exist or is invalid + * @returns merged plugin config */ -export const loadConfig = (name, defaults = {}) => { +export const loadConfig = (name: string, defaults: Partial = {}): PluginConfig => { const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode'); const filePath = path.join(configDir, `${name}.jsonc`); // Get current version from package.json const pluginDir = getPluginDir(); - const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, 'package.json'), 'utf-8')); + const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, 'package.json'), 'utf-8')) as { version: string }; const currentVersion = pkg.version; // Get default config object with current version early so it can be used for peeking @@ -962,15 +984,15 @@ export const loadConfig = (name, defaults = {}) => { copyBundledAssets(configDir); // Try to load existing config - let existingConfig = null; + let existingConfig: Partial | null = null; if (fs.existsSync(filePath)) { try { const content = fs.readFileSync(filePath, 'utf-8'); - existingConfig = parseJSONC(content); + existingConfig = parseJSONC>(content); } catch (error) { // If file is invalid JSONC, we'll use defaults for this run but NOT overwrite the user's file // This prevents accidental loss of configuration due to a simple syntax error - debugLogToFile(`Warning: Config file at ${filePath} is invalid (${error.message}). Using default values for now. Please check your config for syntax errors.`, configDir); + debugLogToFile(`Warning: Config file at ${filePath} is invalid (${getErrorMessage(error)}). Using default values for now. Please check your config for syntax errors.`, configDir); existingConfig = null; // Forces CASE 1 logic but we'll modify it to avoid writing // SMART PEEK: Even if parsing fails, try to see if "enabled" field is set to false/disabled @@ -979,16 +1001,16 @@ export const loadConfig = (name, defaults = {}) => { const rawContent = fs.readFileSync(filePath, 'utf-8'); // Match both boolean and string values for "enabled" const enabledMatch = rawContent.match(/"enabled"\s*:\s*(false|true|"disabled"|"enabled"|'disabled'|'enabled')/i); - if (enabledMatch) { + if (enabledMatch && enabledMatch[1]) { const val = enabledMatch[1].replace(/["']/g, '').toLowerCase(); - const isActuallyEnabled = (val === 'true' || val === 'enabled'); - + const isActuallyEnabled = val === 'true' || val === 'enabled'; + // Inject into defaults and defaultConfig so it's picked up defaults.enabled = isActuallyEnabled; defaultConfig.enabled = isActuallyEnabled; debugLogToFile(`Detected 'enabled: ${isActuallyEnabled}' via emergency regex peek (syntax error in file)`, configDir); } - } catch (e) { + } catch { // Peek failed, just proceed with CASE 1 } } @@ -1014,10 +1036,10 @@ export const loadConfig = (name, defaults = {}) => { } // Return the default config merged with any passed defaults - return { ...defaults, ...defaultConfig }; - } catch (error) { + return { ...defaults, ...defaultConfig } as PluginConfig; + } catch { // If creation fails, return defaults - return { ...defaults, ...defaultConfig }; + return { ...defaults, ...defaultConfig } as PluginConfig; } } @@ -1025,16 +1047,16 @@ export const loadConfig = (name, defaults = {}) => { // CASE 2: Existing config - smart merge to add new fields only // Find what new fields need to be added (for logging) const newFields = findNewFields(defaultConfig, existingConfig); - + // Deep merge: user values preserved, only new fields added from defaults - const mergedConfig = deepMerge(defaultConfig, existingConfig); - + const mergedConfig = deepMerge(defaultConfig, existingConfig) as PluginConfig; + // Update version in merged config mergedConfig._configVersion = currentVersion; // Only write back if there are new fields to add OR version changed const versionChanged = existingConfig._configVersion !== currentVersion; - + if (newFields.length > 0 || versionChanged) { try { // Regenerate the config file with full documentation comments @@ -1050,9 +1072,9 @@ export const loadConfig = (name, defaults = {}) => { } } catch (error) { // If write fails, still return the merged config (just won't persist new fields) - debugLogToFile(`Warning: Could not update config file: ${error.message}`, configDir); + debugLogToFile(`Warning: Could not update config file: ${getErrorMessage(error)}`, configDir); } } - return { ...defaults, ...mergedConfig }; + return { ...defaults, ...mergedConfig } as PluginConfig; }; diff --git a/util/desktop-notify.js b/src/util/desktop-notify.ts similarity index 56% rename from util/desktop-notify.js rename to src/util/desktop-notify.ts index fc343fd..b234103 100644 --- a/util/desktop-notify.js +++ b/src/util/desktop-notify.ts @@ -1,78 +1,104 @@ +import fs from 'fs'; import notifier from 'node-notifier'; import os from 'os'; import path from 'path'; -import fs from 'fs'; + +import type { DesktopNotifyOptions, NotificationResult } from '../types/notification.js'; /** * Desktop Notification Module for OpenCode Smart Voice Notify - * + * * Provides cross-platform native desktop notifications using node-notifier. * Supports Windows Toast, macOS Notification Center, and Linux notify-send. - * + * * Platform-specific behaviors: * - Windows: Uses SnoreToast for Windows 8+ toast notifications * - macOS: Uses terminal-notifier for Notification Center * - Linux: Uses notify-send (requires libnotify-bin package) - * + * * @module util/desktop-notify * @see docs/ARCHITECT_PLAN.md - Phase 1, Task 1.2 */ +interface NotificationSupport { + supported: boolean; + reason?: string; +} + +interface PlatformNotificationOptions { + title: string; + message: string; + sound: boolean; + wait: boolean; + timeout?: number; + subtitle?: string; + urgency?: 'low' | 'normal' | 'critical'; + icon?: string; + 'app-name'?: string; +} + +type NotifyArgs = Parameters; +type NotifyCallback = Exclude; + +const getErrorMessage = (error: unknown): string => { + const maybeError = error as { message?: unknown }; + return String(maybeError?.message); +}; + /** * Debug logging to file. * Only logs when config.debugLog is enabled. * Writes to ~/.config/opencode/logs/smart-voice-notify-debug.log - * - * @param {string} message - Message to log - * @param {boolean} enabled - Whether debug logging is enabled + * + * @param message - Message to log + * @param enabled - Whether debug logging is enabled */ -const debugLog = (message, enabled = false) => { +const debugLog = (message: string, enabled = false): void => { if (!enabled) return; - + try { const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode'); const logsDir = path.join(configDir, 'logs'); - + if (!fs.existsSync(logsDir)) { fs.mkdirSync(logsDir, { recursive: true }); } - + const logFile = path.join(logsDir, 'smart-voice-notify-debug.log'); const timestamp = new Date().toISOString(); fs.appendFileSync(logFile, `[${timestamp}] [desktop-notify] ${message}\n`); - } catch (e) { + } catch { // Silently fail - logging should never break the plugin } }; /** * Get the current platform identifier. - * @returns {'darwin' | 'win32' | 'linux'} Platform string */ -export const getPlatform = () => os.platform(); +export const getPlatform = (): NodeJS.Platform => os.platform(); /** * Check if desktop notifications are likely to work on this platform. - * - * @returns {{ supported: boolean, reason?: string }} Support status and reason if not supported + * + * @returns Support status and reason if not supported */ -export const checkNotificationSupport = () => { +export const checkNotificationSupport = (): NotificationSupport => { const platform = getPlatform(); - + switch (platform) { case 'darwin': // macOS always supports notifications via terminal-notifier (bundled) return { supported: true }; - + case 'win32': // Windows 8+ supports toast notifications via SnoreToast (bundled) return { supported: true }; - + case 'linux': // Linux requires notify-send from libnotify-bin package // We don't check for its existence here - node-notifier handles the fallback return { supported: true }; - + default: return { supported: false, reason: `Unsupported platform: ${platform}` }; } @@ -81,62 +107,61 @@ export const checkNotificationSupport = () => { /** * Build platform-specific notification options. * Normalizes options across different platforms while respecting their unique capabilities. - * - * @param {string} title - Notification title - * @param {string} message - Notification body/message - * @param {object} options - Additional options - * @param {number} [options.timeout=5] - Notification timeout in seconds - * @param {boolean} [options.sound=false] - Whether to play a sound (platform-specific) - * @param {string} [options.icon] - Absolute path to notification icon - * @param {string} [options.subtitle] - Subtitle (macOS only) - * @param {string} [options.urgency] - Urgency level: 'low', 'normal', 'critical' (Linux only) - * @returns {object} Platform-normalized notification options + * + * @param title - Notification title + * @param message - Notification body/message + * @param options - Additional options + * @returns Platform-normalized notification options */ -const buildPlatformOptions = (title, message, options = {}) => { +const buildPlatformOptions = ( + title: string, + message: string, + options: DesktopNotifyOptions = {}, +): PlatformNotificationOptions => { const platform = getPlatform(); const { timeout = 5, sound = false, icon, subtitle, urgency } = options; - + // Base options common to all platforms - const baseOptions = { + const baseOptions: PlatformNotificationOptions = { title: title || 'OpenCode', message: message || '', - sound: sound, - wait: false // Don't block - fire and forget + sound, + wait: false, // Don't block - fire and forget }; - + // Add icon if provided and exists if (icon && fs.existsSync(icon)) { baseOptions.icon = icon; } - + // Platform-specific options switch (platform) { case 'darwin': // macOS Notification Center options return { ...baseOptions, - timeout: timeout, - subtitle: subtitle || undefined + timeout, + subtitle: subtitle || undefined, }; - + case 'win32': // Windows Toast options return { ...baseOptions, // Windows doesn't use timeout the same way - notifications persist until dismissed // sound can be true/false or a system sound name - sound: sound + sound, }; - + case 'linux': // Linux notify-send options return { ...baseOptions, - timeout: timeout, // Timeout in seconds + timeout, // Timeout in seconds urgency: urgency || 'normal', // low, normal, critical - 'app-name': 'OpenCode Smart Notify' + 'app-name': 'OpenCode Smart Notify', }; - + default: return baseOptions; } @@ -144,26 +169,20 @@ const buildPlatformOptions = (title, message, options = {}) => { /** * Send a native desktop notification. - * + * * This is the main function for sending cross-platform desktop notifications. * It handles platform-specific options and gracefully fails if notifications * are not supported or the notifier encounters an error. - * - * @param {string} title - Notification title - * @param {string} message - Notification body/message - * @param {object} [options={}] - Notification options - * @param {number} [options.timeout=5] - Notification timeout in seconds - * @param {boolean} [options.sound=false] - Whether to play a sound - * @param {string} [options.icon] - Absolute path to notification icon - * @param {string} [options.subtitle] - Subtitle (macOS only) - * @param {string} [options.urgency='normal'] - Urgency level (Linux only) - * @param {boolean} [options.debugLog=false] - Enable debug logging - * @returns {Promise<{ success: boolean, error?: string }>} Result object - * + * + * @param title - Notification title + * @param message - Notification body/message + * @param options - Notification options + * @returns Result object + * * @example * // Simple notification * await sendDesktopNotification('Task Complete', 'Your code is ready for review'); - * + * * @example * // With options * await sendDesktopNotification('Permission Required', 'Agent needs approval', { @@ -172,11 +191,15 @@ const buildPlatformOptions = (title, message, options = {}) => { * sound: true * }); */ -export const sendDesktopNotification = async (title, message, options = {}) => { +export const sendDesktopNotification = async ( + title: string, + message: string, + options: DesktopNotifyOptions = {}, +): Promise => { // Handle null/undefined options gracefully const opts = options || {}; const debug = opts.debugLog || false; - + try { // Check platform support const support = checkNotificationSupport(); @@ -184,126 +207,148 @@ export const sendDesktopNotification = async (title, message, options = {}) => { debugLog(`Notification not supported: ${support.reason}`, debug); return { success: false, error: support.reason }; } - + // Build platform-specific options const notifyOptions = buildPlatformOptions(title, message, opts); - + debugLog(`Sending notification: "${title}" - "${message}" (platform: ${getPlatform()})`, debug); - - // Send notification using promise wrapper - return new Promise((resolve) => { - notifier.notify(notifyOptions, (error, response) => { + + // Send notification using promise wrapper. + // Some environments never invoke the notifier callback; add a safety timeout. + return await new Promise((resolve) => { + let settled = false; + + const settle = (result: NotificationResult): void => { + if (settled) return; + settled = true; + clearTimeout(safetyTimeout); + resolve(result); + }; + + const callbackTimeoutMs = Math.min(1200, Math.max(200, (Number(opts.timeout ?? 5) || 5) * 1000 + 250)); + const safetyTimeout = setTimeout(() => { + debugLog(`Notification callback timeout after ${callbackTimeoutMs}ms`, debug); + settle({ success: false, error: 'Notification callback timeout' }); + }, callbackTimeoutMs); + + const callback: NotifyCallback = (error, response) => { if (error) { debugLog(`Notification error: ${error.message}`, debug); - resolve({ success: false, error: error.message }); - } else { - debugLog(`Notification sent successfully (response: ${response})`, debug); - resolve({ success: true }); + settle({ success: false, error: error.message }); + return; } - }); + + debugLog(`Notification sent successfully (response: ${response})`, debug); + settle({ success: true }); + }; + + try { + notifier.notify(notifyOptions as NotifyArgs[0], callback); + } catch (notifyError) { + settle({ success: false, error: getErrorMessage(notifyError) }); + } }); } catch (error) { - debugLog(`Notification exception: ${error.message}`, debug); - return { success: false, error: error.message }; + const messageText = getErrorMessage(error); + debugLog(`Notification exception: ${messageText}`, debug); + return { success: false, error: messageText }; } }; /** * Send a notification for session idle (task completion). * Pre-configured for task completion notifications. - * - * @param {string} message - Notification message - * @param {object} [options={}] - Additional options - * @param {string} [options.projectName] - Project name to include in title - * @param {boolean} [options.debugLog=false] - Enable debug logging - * @returns {Promise<{ success: boolean, error?: string }>} Result object + * + * @param message - Notification message + * @param options - Additional options + * @returns Result object */ -export const notifyTaskComplete = async (message, options = {}) => { - const title = options.projectName - ? `✅ ${options.projectName} - Task Complete` - : '✅ OpenCode - Task Complete'; - +export const notifyTaskComplete = async ( + message: string, + options: DesktopNotifyOptions = {}, +): Promise => { + const title = options.projectName ? `✅ ${options.projectName} - Task Complete` : '✅ OpenCode - Task Complete'; + return sendDesktopNotification(title, message, { timeout: 5, sound: false, // We handle sound separately in the main plugin - ...options + ...options, }); }; /** * Send a notification for permission requests. * Pre-configured for permission request notifications (more urgent). - * - * @param {string} message - Notification message - * @param {object} [options={}] - Additional options - * @param {string} [options.projectName] - Project name to include in title - * @param {number} [options.count=1] - Number of permission requests - * @param {boolean} [options.debugLog=false] - Enable debug logging - * @returns {Promise<{ success: boolean, error?: string }>} Result object + * + * @param message - Notification message + * @param options - Additional options + * @returns Result object */ -export const notifyPermissionRequest = async (message, options = {}) => { +export const notifyPermissionRequest = async ( + message: string, + options: DesktopNotifyOptions = {}, +): Promise => { const count = options.count || 1; - const title = options.projectName + const title = options.projectName ? `⚠️ ${options.projectName} - Permission Required` - : count > 1 + : count > 1 ? `⚠️ ${count} Permissions Required` : '⚠️ OpenCode - Permission Required'; - + return sendDesktopNotification(title, message, { timeout: 10, // Longer timeout for permissions urgency: 'critical', // Higher urgency on Linux sound: false, // We handle sound separately - ...options + ...options, }); }; /** * Send a notification for question requests (SDK v1.1.7+). * Pre-configured for question notifications. - * - * @param {string} message - Notification message - * @param {object} [options={}] - Additional options - * @param {string} [options.projectName] - Project name to include in title - * @param {number} [options.count=1] - Number of questions - * @param {boolean} [options.debugLog=false] - Enable debug logging - * @returns {Promise<{ success: boolean, error?: string }>} Result object + * + * @param message - Notification message + * @param options - Additional options + * @returns Result object */ -export const notifyQuestion = async (message, options = {}) => { +export const notifyQuestion = async ( + message: string, + options: DesktopNotifyOptions = {}, +): Promise => { const count = options.count || 1; - const title = options.projectName + const title = options.projectName ? `❓ ${options.projectName} - Question` - : count > 1 + : count > 1 ? `❓ ${count} Questions Need Your Input` : '❓ OpenCode - Question'; - + return sendDesktopNotification(title, message, { timeout: 8, urgency: 'normal', sound: false, // We handle sound separately - ...options + ...options, }); }; /** * Send a notification for error events. * Pre-configured for error notifications (most urgent). - * - * @param {string} message - Notification message - * @param {object} [options={}] - Additional options - * @param {string} [options.projectName] - Project name to include in title - * @param {boolean} [options.debugLog=false] - Enable debug logging - * @returns {Promise<{ success: boolean, error?: string }>} Result object + * + * @param message - Notification message + * @param options - Additional options + * @returns Result object */ -export const notifyError = async (message, options = {}) => { - const title = options.projectName - ? `❌ ${options.projectName} - Error` - : '❌ OpenCode - Error'; - +export const notifyError = async ( + message: string, + options: DesktopNotifyOptions = {}, +): Promise => { + const title = options.projectName ? `❌ ${options.projectName} - Error` : '❌ OpenCode - Error'; + return sendDesktopNotification(title, message, { timeout: 15, // Longer timeout for errors urgency: 'critical', sound: false, // We handle sound separately - ...options + ...options, }); }; @@ -315,5 +360,5 @@ export default { notifyQuestion, notifyError, checkNotificationSupport, - getPlatform + getPlatform, }; diff --git a/src/util/focus-detect.ts b/src/util/focus-detect.ts new file mode 100644 index 0000000..710ff84 --- /dev/null +++ b/src/util/focus-detect.ts @@ -0,0 +1,999 @@ +import { exec, type ExecOptionsWithStringEncoding } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import detectTerminal from 'detect-terminal'; +import { promisify } from 'util'; + +import { createLinuxPlatform } from './linux.js'; +import type { ShellRunner } from '../types/opencode-sdk.js'; + +/** + * Focus Detection Module for OpenCode Smart Voice Notify + * + * Detects whether the user is currently looking at the OpenCode terminal. + * Used to suppress notifications when the user is already focused on the terminal. + * + * Platform support: + * - macOS: Full support using AppleScript to check frontmost app + * - Windows: Full support using PowerShell + Get-Process + * - Linux: X11 (xdotool/xprop) and Wayland (Sway/GNOME/KDE) + * + * @module util/focus-detect + * @see docs/ARCHITECT_PLAN.md - Phase 3, Task 3.2 + */ + +type ExecAsync = ( + command: string, + options?: ExecOptionsWithStringEncoding, +) => Promise<{ stdout: string; stderr: string }>; + +interface FocusCacheState { + isFocused: boolean; + timestamp: number; + terminalName: string | null; +} + +interface FocusDetectionSupport { + supported: boolean; + reason?: string; +} + +interface TerminalFocusOptions { + debugLog?: boolean; + shellRunner?: ShellRunner; +} + +const getErrorMessage = (error: unknown): string => { + const maybeError = error as { message?: unknown }; + return String(maybeError?.message); +}; + +const execAsync = promisify(exec) as ExecAsync; + +const toUtf8Text = (value: Buffer | Uint8Array | string | null | undefined): string => { + if (typeof value === 'string') { + return value; + } + + if (!value) { + return ''; + } + + return Buffer.from(value).toString('utf8'); +}; + +const executeCommand = async ( + command: string, + options: ExecOptionsWithStringEncoding, + shellRunner?: ShellRunner, +): Promise<{ stdout: string; stderr: string }> => { + if (!shellRunner) { + return execAsync(command, options); + } + + const execution = shellRunner`${command}`.quiet().nothrow(); + if (typeof execution.timeout === 'function') { + execution.timeout(typeof options.timeout === 'number' ? options.timeout : 2000); + } + + const shellResult = await execution; + + return { + stdout: toUtf8Text(shellResult.stdout), + stderr: toUtf8Text(shellResult.stderr), + }; +}; + +// ======================================== +// CACHING CONFIGURATION +// ======================================== + +/** + * Cache for focus detection results. + * Prevents excessive system calls (AppleScript execution). + */ +let focusCache: FocusCacheState = { + isFocused: false, + timestamp: 0, + terminalName: null, +}; + +/** + * Cache TTL in milliseconds. + * Focus detection results are cached for this duration. + * 500ms provides a good balance between responsiveness and performance. + */ +const CACHE_TTL_MS = 500; + +/** + * List of known terminal application names for macOS. + * These are matched against the frontmost application name. + * The detect-terminal package helps identify which terminal is in use. + */ +export const KNOWN_TERMINALS_MACOS = [ + 'Terminal', + 'iTerm', + 'iTerm2', + 'Hyper', + 'Alacritty', + 'kitty', + 'WezTerm', + 'Tabby', + 'Warp', + 'Rio', + 'Ghostty', +] as const; + +/** + * List of known terminal application names and process names for Windows. + * These are matched against the focused process name from PowerShell. + */ +export const KNOWN_TERMINALS_WINDOWS = [ + 'Windows Terminal', + 'WindowsTerminal', + 'cmd', + 'cmd.exe', + 'Command Prompt', + 'PowerShell', + 'powershell', + 'pwsh', + 'conhost', + 'Alacritty', + 'kitty', + 'WezTerm', + 'Hyper', + 'Tabby', + 'Warp', + 'Rio', + 'Ghostty', + // Unix-like shells on Windows + 'Git Bash', + 'bash', + 'MINGW64', + 'Cygwin', + 'MSYS2', +] as const; + +/** + * List of known terminal application names and window classes for Linux. + */ +export const KNOWN_TERMINALS_LINUX = [ + 'gnome-terminal', + 'gnome terminal', + 'gnome-terminal-server', + 'konsole', + 'xfce4-terminal', + 'mate-terminal', + 'lxterminal', + 'terminator', + 'tilix', + 'terminology', + 'kitty', + 'alacritty', + 'wezterm', + 'hyper', + 'tabby', + 'warp', + 'rio', + 'ghostty', + 'foot', + 'xterm', + 'urxvt', + 'rxvt', + 'st', +] as const; + +// ======================================== +// DEBUG LOGGING +// ======================================== + +/** + * Debug logging to file. + * Only logs when enabled. + * Writes to ~/.config/opencode/logs/smart-voice-notify-debug.log + * + * @param message - Message to log + * @param enabled - Whether debug logging is enabled + */ +const debugLog = (message: string, enabled = false): void => { + if (!enabled) return; + + try { + const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode'); + const logsDir = path.join(configDir, 'logs'); + + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + const logFile = path.join(logsDir, 'smart-voice-notify-debug.log'); + const timestamp = new Date().toISOString(); + fs.appendFileSync(logFile, `[${timestamp}] [focus-detect] ${message}\n`); + } catch { + // Silently fail - logging should never break the plugin + } +}; + +// ======================================== +// PLATFORM DETECTION +// ======================================== + +/** + * Get the current platform identifier. + */ +export const getPlatform = (): NodeJS.Platform => os.platform(); + +/** + * Check if focus detection is supported on this platform. + * + * @returns Support status + */ +export const isFocusDetectionSupported = (): FocusDetectionSupport => { + const platform = getPlatform(); + + switch (platform) { + case 'darwin': + return { supported: true }; + case 'win32': + return { supported: true }; + case 'linux': + return { supported: true }; + default: + return { supported: false, reason: `Unsupported platform: ${platform}` }; + } +}; + +// ======================================== +// TERMINAL DETECTION +// ======================================== + +/** + * Detect the current terminal emulator using detect-terminal package. + * Caches the result since the terminal doesn't change during execution. + * + * @param debug - Enable debug logging + * @returns Terminal name or null if not detected + */ +let cachedTerminalName: string | null = null; +let terminalDetectionAttempted = false; + +export const getTerminalName = (debug = false): string | null => { + // Return cached result if already detected + if (terminalDetectionAttempted) { + return cachedTerminalName; + } + + try { + terminalDetectionAttempted = true; + // Prefer the outer terminal (GUI app) over multiplexers like tmux/screen + const terminal = detectTerminal({ preferOuter: true }); + cachedTerminalName = terminal || null; + debugLog(`Detected terminal: ${cachedTerminalName}`, debug); + return cachedTerminalName; + } catch (error) { + debugLog(`Terminal detection failed: ${getErrorMessage(error)}`, debug); + return null; + } +}; + +// ======================================== +// FOCUS DETECTION - macOS +// ======================================== + +/** + * AppleScript to get the frontmost application name. + * Uses System Events to determine which app is currently focused. + * Includes checks for: + * - App visibility (not hidden with Cmd+H) + * - Visible, non-minimized windows + * Returns empty string if app is hidden or has no visible windows. + */ +const APPLESCRIPT_GET_FRONTMOST = ` +tell application "System Events" + set frontApp to first application process whose frontmost is true + + -- Check if app is visible (not hidden with Cmd+H) + if visible of frontApp is false then + return "" + end if + + -- Check if the app has any visible, non-minimized windows + try + set windowList to every window of frontApp whose visible is true and miniaturized is false + if (count of windowList) is 0 then + return "" + end if + end try + + return name of frontApp +end tell +`; + +/** + * PowerShell script to get the frontmost process on Windows. + * Uses user32.dll to get foreground window handle, then Get-Process by PID. + * Includes checks for minimized (IsIconic) and invisible (IsWindowVisible) windows. + */ +const POWERSHELL_GET_FRONTMOST_PROCESS = ` +Add-Type @" +using System; +using System.Runtime.InteropServices; + +public static class Win32FocusDetect { + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out int lpdwProcessId); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsIconic(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWindowVisible(IntPtr hWnd); +} +"@ + +$processId = 0 +$foregroundWindow = [Win32FocusDetect]::GetForegroundWindow() + +# No foreground window (e.g., showing desktop) +if ($foregroundWindow -eq [IntPtr]::Zero) { + return +} + +# Check if window is minimized (iconic) - if so, not truly focused +if ([Win32FocusDetect]::IsIconic($foregroundWindow)) { + return +} + +# Check if window is visible - if not, not truly focused +if (-not [Win32FocusDetect]::IsWindowVisible($foregroundWindow)) { + return +} + +[Win32FocusDetect]::GetWindowThreadProcessId($foregroundWindow, [ref]$processId) | Out-Null +if ($processId -le 0) { + return +} + +Get-Process -Id $processId | Select-Object -ExpandProperty ProcessName +`; + +const getEncodedPowerShellScript = (script: string): string => + Buffer.from(script, 'utf16le').toString('base64'); + +/** + * Get the name of the frontmost application on macOS. + * + * @param debug - Enable debug logging + * @returns Frontmost app name or null on error + */ +const getFrontmostAppMacOS = async (debug = false, shellRunner?: ShellRunner): Promise => { + try { + const { stdout } = await executeCommand( + `osascript -e '${APPLESCRIPT_GET_FRONTMOST}'`, + { + encoding: 'utf8', + timeout: 2000, // 2 second timeout + maxBuffer: 1024, // Small buffer - we only expect app name + }, + shellRunner, + ); + + const appName = stdout.trim(); + debugLog(`Frontmost app: "${appName}"`, debug); + return appName; + } catch (error) { + debugLog(`Failed to get frontmost app: ${getErrorMessage(error)}`, debug); + return null; + } +}; + +/** + * Get the focused process name on Windows via PowerShell. + * + * @param debug - Enable debug logging + * @param shellRunner - Optional shell runner override for testing + * @returns Focused process name or null on error + */ +const getFrontmostAppWindows = async (debug = false, shellRunner?: ShellRunner): Promise => { + try { + const encodedScript = getEncodedPowerShellScript(POWERSHELL_GET_FRONTMOST_PROCESS); + const command = + `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedScript}`; + + const { stdout, stderr } = await executeCommand( + command, + { + encoding: 'utf8', + timeout: 2000, + maxBuffer: 1024, + }, + shellRunner, + ); + + if (stderr.trim()) { + debugLog(`PowerShell stderr: ${stderr.trim()}`, debug); + } + + const processName = stdout.trim(); + if (!processName) { + debugLog('PowerShell returned empty focused process name', debug); + return null; + } + + debugLog(`Frontmost process: "${processName}"`, debug); + return processName; + } catch (error) { + debugLog(`Failed to get frontmost Windows process: ${getErrorMessage(error)}`, debug); + return null; + } +}; + +const getLinuxSessionType = (): 'x11' | 'wayland' | 'tty' | 'unknown' => { + try { + return createLinuxPlatform({}).getSessionType(); + } catch { + return 'unknown'; + } +}; + +const runLinuxFocusCommand = async ( + command: string, + debug: boolean, + label: string, + shellRunner?: ShellRunner, +): Promise => { + try { + const { stdout, stderr } = await executeCommand( + command, + { + encoding: 'utf8', + timeout: 2000, + maxBuffer: 1024 * 1024, + }, + shellRunner, + ); + + if (stderr.trim()) { + debugLog(`${label}: stderr: ${stderr.trim()}`, debug); + } + + const output = stdout.trim(); + if (!output) { + debugLog(`${label}: empty output`, debug); + return null; + } + + return output; + } catch (error) { + debugLog(`${label}: command failed: ${getErrorMessage(error)}`, debug); + return null; + } +}; + +const parseQuotedValues = (text: string): string[] => { + const values: string[] = []; + const matches = text.match(/"([^"\\]*(?:\\.[^"\\]*)*)"/g) || []; + + for (const match of matches) { + const value = match.slice(1, -1).trim(); + if (value) { + values.push(value); + } + } + + return values; +}; + +const getFrontmostAppLinuxX11 = async (debug = false, shellRunner?: ShellRunner): Promise => { + if (!process.env.DISPLAY) { + debugLog('linux.x11: DISPLAY not set', debug); + return null; + } + + const xdotoolClass = await runLinuxFocusCommand( + 'xdotool getwindowfocus getwindowclassname', + debug, + 'linux.x11.xdotool-class', + shellRunner, + ); + if (xdotoolClass) { + debugLog(`linux.x11: focused class from xdotool: "${xdotoolClass}"`, debug); + return xdotoolClass; + } + + const xdotoolName = await runLinuxFocusCommand( + 'xdotool getwindowfocus getwindowname', + debug, + 'linux.x11.xdotool-name', + shellRunner, + ); + if (xdotoolName) { + debugLog(`linux.x11: focused name from xdotool: "${xdotoolName}"`, debug); + return xdotoolName; + } + + const activeWindow = await runLinuxFocusCommand( + 'xprop -root _NET_ACTIVE_WINDOW', + debug, + 'linux.x11.xprop-active-window', + shellRunner, + ); + + const windowId = activeWindow?.match(/0x[0-9a-f]+/i)?.[0] || null; + if (!windowId) { + debugLog('linux.x11: active window id parse failed', debug); + return null; + } + + const windowProps = await runLinuxFocusCommand( + `xprop -id ${windowId} WM_CLASS WM_NAME`, + debug, + 'linux.x11.xprop-window-props', + shellRunner, + ); + + if (!windowProps) { + return null; + } + + const quotedValues = parseQuotedValues(windowProps); + const focused = quotedValues.find(Boolean) || null; + if (focused) { + debugLog(`linux.x11: focused value from xprop: "${focused}"`, debug); + } + + return focused; +}; + +interface SwayTreeNode { + focused?: boolean; + name?: string; + app_id?: string; + window_properties?: { + class?: string; + instance?: string; + title?: string; + }; + nodes?: SwayTreeNode[]; + floating_nodes?: SwayTreeNode[]; +} + +const findFocusedSwayNode = (node: SwayTreeNode): SwayTreeNode | null => { + if (node.focused) { + return node; + } + + const children = [...(node.nodes || []), ...(node.floating_nodes || [])]; + for (const child of children) { + const result = findFocusedSwayNode(child); + if (result) { + return result; + } + } + + return null; +}; + +const getFrontmostAppWaylandSway = async (debug = false, shellRunner?: ShellRunner): Promise => { + const treeOutput = await runLinuxFocusCommand( + 'swaymsg -t get_tree', + debug, + 'linux.wayland.swaymsg', + shellRunner, + ); + + if (!treeOutput) { + return null; + } + + try { + const tree = JSON.parse(treeOutput) as SwayTreeNode; + const focused = findFocusedSwayNode(tree); + if (!focused) { + debugLog('linux.wayland.swaymsg: focused node not found', debug); + return null; + } + + const name = + focused.app_id || + focused.window_properties?.class || + focused.window_properties?.instance || + focused.name || + focused.window_properties?.title || + null; + + if (name) { + debugLog(`linux.wayland.swaymsg: focused app "${name}"`, debug); + } + + return name; + } catch (error) { + debugLog(`linux.wayland.swaymsg: parse failed: ${getErrorMessage(error)}`, debug); + return null; + } +}; + +const parseGdbusEvalResult = (output: string): string | null => { + const match = output.match(/^\((true|false),\s*(.*)\)$/s); + if (!match) { + return output.trim() || null; + } + + if (match[1] !== 'true') { + return null; + } + + let value = match[2]?.trim() || ''; + if (!value || value === "''" || value === '""') { + return null; + } + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + return value.trim() || null; +}; + +const getFrontmostAppWaylandGnome = async (debug = false, shellRunner?: ShellRunner): Promise => { + const command = + 'gdbus call --session --dest org.gnome.Shell --object-path /org/gnome/Shell --method org.gnome.Shell.Eval "(() => { const w = global.display.focus_window; if (!w) return \"\"; return [w.get_wm_class(), w.get_title()].filter(Boolean).join(\" - \" ); })()"'; + + const result = await runLinuxFocusCommand(command, debug, 'linux.wayland.gdbus', shellRunner); + if (!result) { + return null; + } + + const parsed = parseGdbusEvalResult(result); + if (parsed) { + debugLog(`linux.wayland.gdbus: focused app "${parsed}"`, debug); + } + + return parsed; +}; + +const getFrontmostAppWaylandKde = async (debug = false, shellRunner?: ShellRunner): Promise => { + const activeWindow = await runLinuxFocusCommand( + 'qdbus org.kde.KWin /KWin org.kde.KWin.activeWindow', + debug, + 'linux.wayland.qdbus-active-window', + shellRunner, + ); + + if (!activeWindow) { + return null; + } + + const caption = await runLinuxFocusCommand( + `qdbus org.kde.KWin /KWin org.kde.KWin.caption ${activeWindow}`, + debug, + 'linux.wayland.qdbus-caption', + shellRunner, + ); + + const windowClass = await runLinuxFocusCommand( + `qdbus org.kde.KWin /KWin org.kde.KWin.windowClass ${activeWindow}`, + debug, + 'linux.wayland.qdbus-window-class', + shellRunner, + ); + + const appName = windowClass || caption || activeWindow; + debugLog(`linux.wayland.qdbus: focused app "${appName}"`, debug); + return appName; +}; + +type LinuxDesktopEnvironment = 'sway' | 'gnome' | 'kde' | 'unknown'; + +const detectWaylandDesktopEnvironment = (): LinuxDesktopEnvironment => { + const desktopInfo = [ + process.env.XDG_CURRENT_DESKTOP, + process.env.XDG_SESSION_DESKTOP, + process.env.DESKTOP_SESSION, + ] + .filter(Boolean) + .join(':') + .toLowerCase(); + + if (desktopInfo.includes('sway') || !!process.env.SWAYSOCK) { + return 'sway'; + } + if (desktopInfo.includes('gnome')) { + return 'gnome'; + } + if (desktopInfo.includes('kde') || desktopInfo.includes('plasma')) { + return 'kde'; + } + + return 'unknown'; +}; + +const getFrontmostAppLinuxWayland = async (debug = false, shellRunner?: ShellRunner): Promise => { + const desktop = detectWaylandDesktopEnvironment(); + debugLog(`linux.wayland: desktop detected as ${desktop}`, debug); + + if (desktop === 'sway') { + const swayResult = await getFrontmostAppWaylandSway(debug, shellRunner); + if (swayResult) { + return swayResult; + } + } + + if (desktop === 'gnome') { + const gnomeResult = await getFrontmostAppWaylandGnome(debug, shellRunner); + if (gnomeResult) { + return gnomeResult; + } + } + + if (desktop === 'kde') { + const kdeResult = await getFrontmostAppWaylandKde(debug, shellRunner); + if (kdeResult) { + return kdeResult; + } + } + + const swayResult = await getFrontmostAppWaylandSway(debug, shellRunner); + if (swayResult) { + return swayResult; + } + + const gnomeResult = await getFrontmostAppWaylandGnome(debug, shellRunner); + if (gnomeResult) { + return gnomeResult; + } + + return await getFrontmostAppWaylandKde(debug, shellRunner); +}; + +/** + * Get the focused Linux app/window name via X11 or Wayland fallback chain. + */ +const getFrontmostAppLinux = async (debug = false, shellRunner?: ShellRunner): Promise => { + const sessionType = getLinuxSessionType(); + debugLog(`linux: session type detected as ${sessionType}`, debug); + + if (sessionType === 'x11') { + return await getFrontmostAppLinuxX11(debug, shellRunner); + } + + if (sessionType === 'wayland') { + return await getFrontmostAppLinuxWayland(debug, shellRunner); + } + + if (sessionType === 'tty') { + debugLog('linux: tty session - no focus window available', debug); + return null; + } + + const x11Result = await getFrontmostAppLinuxX11(debug, shellRunner); + if (x11Result) { + return x11Result; + } + + return await getFrontmostAppLinuxWayland(debug, shellRunner); +}; + +/** + * Check if the frontmost app is a known terminal on macOS. + * + * @param appName - The frontmost application name + * @param debug - Enable debug logging + * @returns True if the app is a known terminal + */ +const normalizeTerminalName = (name: string): string => name.trim().toLowerCase().replace(/\.exe$/i, ''); + +const isKnownTerminal = ( + appName: string | null, + knownTerminals: readonly string[], + debug = false, + useDetectedTerminal = true, +): boolean => { + if (!appName) return false; + + const normalizedAppName = normalizeTerminalName(appName); + + // Direct match + if (knownTerminals.some((t) => normalizeTerminalName(t) === normalizedAppName)) { + debugLog(`"${appName}" is a known terminal (direct match)`, debug); + return true; + } + + // Partial match (for apps like "iTerm2" matching "iTerm") + if (knownTerminals.some((t) => normalizedAppName.includes(normalizeTerminalName(t)))) { + debugLog(`"${appName}" is a known terminal (partial match)`, debug); + return true; + } + + // Check if the detected terminal from detect-terminal matches + if (useDetectedTerminal) { + const detectedTerminal = getTerminalName(debug); + if (detectedTerminal && normalizedAppName.includes(normalizeTerminalName(detectedTerminal))) { + debugLog(`"${appName}" matches detected terminal "${detectedTerminal}"`, debug); + return true; + } + } + + debugLog(`"${appName}" is NOT a known terminal`, debug); + return false; +}; + +// ======================================== +// MAIN FOCUS DETECTION FUNCTION +// ======================================== + +/** + * Check if the OpenCode terminal is currently focused. + * + * This function detects whether the user is currently looking at the terminal + * where OpenCode is running. Used to suppress notifications when the user + * is already paying attention to the terminal. + * + * Platform behavior: + * - macOS: Uses AppleScript to check the frontmost application + * - Windows: Uses PowerShell to check focused window process name + * - Linux: Uses X11/Wayland specific focus detection commands + * + * Results are cached for 500ms to avoid excessive system calls. + * + * @param options - Options + * @returns True if terminal is focused, false otherwise + * + * @example + * const focused = await isTerminalFocused({ debugLog: true }); + * if (focused) { + * console.log('User is looking at the terminal - skip notification'); + * } + */ +export const isTerminalFocused = async (options: TerminalFocusOptions = {}): Promise => { + const debug = options?.debugLog || false; + const now = Date.now(); + + // Check cache first + if (now - focusCache.timestamp < CACHE_TTL_MS) { + debugLog(`Using cached focus result: ${focusCache.isFocused}`, debug); + return focusCache.isFocused; + } + + const platform = getPlatform(); + + // Platform-specific implementation + if (platform === 'darwin') { + try { + const frontmostApp = await getFrontmostAppMacOS(debug, options.shellRunner); + const isFocused = isKnownTerminal(frontmostApp, KNOWN_TERMINALS_MACOS, debug); + + // Update cache + focusCache = { + isFocused, + timestamp: now, + terminalName: frontmostApp, + }; + + debugLog(`Focus detection complete: ${isFocused} (frontmost: "${frontmostApp}")`, debug); + return isFocused; + } catch (error) { + debugLog(`Focus detection error: ${getErrorMessage(error)}`, debug); + // On error, assume not focused (fail open - still notify) + focusCache = { + isFocused: false, + timestamp: now, + terminalName: null, + }; + return false; + } + } + + if (platform === 'win32') { + try { + const frontmostProcess = await getFrontmostAppWindows(debug, options.shellRunner); + const isFocused = isKnownTerminal(frontmostProcess, KNOWN_TERMINALS_WINDOWS, debug, false); + + focusCache = { + isFocused, + timestamp: now, + terminalName: frontmostProcess, + }; + + debugLog( + `Focus detection complete: ${isFocused} (frontmost process: "${frontmostProcess}")`, + debug, + ); + return isFocused; + } catch (error) { + debugLog(`Windows focus detection error: ${getErrorMessage(error)}`, debug); + focusCache = { + isFocused: false, + timestamp: now, + terminalName: null, + }; + return false; + } + } + + if (platform === 'linux') { + try { + const frontmostApp = await getFrontmostAppLinux(debug, options.shellRunner); + const isFocused = isKnownTerminal(frontmostApp, KNOWN_TERMINALS_LINUX, debug); + + focusCache = { + isFocused, + timestamp: now, + terminalName: frontmostApp, + }; + + debugLog(`Focus detection complete: ${isFocused} (frontmost app: "${frontmostApp}")`, debug); + return isFocused; + } catch (error) { + debugLog(`Linux focus detection error: ${getErrorMessage(error)}`, debug); + focusCache = { + isFocused: false, + timestamp: now, + terminalName: null, + }; + return false; + } + } + + // Other platforms: Not supported + debugLog(`Focus detection not supported on platform: ${platform}`, debug); + + // Cache the result even for unsupported platforms + focusCache = { + isFocused: false, + timestamp: now, + terminalName: null, + }; + + return false; +}; + +/** + * Clear the focus detection cache. + * Useful for testing or when forcing a fresh check. + */ +export const clearFocusCache = (): void => { + focusCache = { + isFocused: false, + timestamp: 0, + terminalName: null, + }; +}; + +/** + * Reset the terminal detection cache. + * Useful for testing. + */ +export const resetTerminalDetection = (): void => { + cachedTerminalName = null; + terminalDetectionAttempted = false; +}; + +/** + * Get the current cache state. + * Useful for testing and debugging. + */ +export const getCacheState = (): FocusCacheState => ({ ...focusCache }); + +// Default export for convenience +export default { + isTerminalFocused, + isFocusDetectionSupported, + getTerminalName, + getPlatform, + clearFocusCache, + resetTerminalDetection, + getCacheState, + KNOWN_TERMINALS_MACOS, + KNOWN_TERMINALS_WINDOWS, + KNOWN_TERMINALS_LINUX, +}; diff --git a/util/linux.js b/src/util/linux.ts similarity index 55% rename from util/linux.js rename to src/util/linux.ts index f86b025..c910342 100644 --- a/util/linux.js +++ b/src/util/linux.ts @@ -1,58 +1,71 @@ +import type { LinuxPlatformAPI, LinuxPlatformParams, LinuxSessionType } from '../types/linux.js'; +import type { ShellRunner } from '../types/opencode-sdk.js'; + /** * Linux Platform Compatibility Module - * + * * Provides Linux-specific implementations for: * - Wake monitor from sleep (X11 and Wayland) * - Get current system volume (PulseAudio/PipeWire and ALSA) * - Force system volume up (PulseAudio/PipeWire and ALSA) * - Play audio files (PulseAudio and ALSA) - * + * * Dependencies (optional - graceful fallback if missing): * - x11-xserver-utils (for xset on X11) * - pulseaudio-utils or pipewire-pulse (for pactl) * - alsa-utils (for amixer, aplay, paplay) - * + * * @module util/linux */ +const getErrorMessage = (error: unknown): string => { + const maybeError = error as { message?: unknown }; + return String(maybeError?.message); +}; + /** * Creates a Linux platform utilities instance - * @param {object} params - { $: shell runner, debugLog: logging function } - * @returns {object} Linux platform API + * @param params - { $: shell runner, debugLog: logging function } + * @returns Linux platform API */ -export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { - +export const createLinuxPlatform = ({ $, debugLog = () => {} }: LinuxPlatformParams): LinuxPlatformAPI => { + const shell: ShellRunner | undefined = $; + // ============================================================ // DISPLAY SESSION DETECTION // ============================================================ - + /** * Detect if running under Wayland - * @returns {boolean} + * @returns */ - const isWayland = () => { + const isWayland = (): boolean => { return !!process.env.WAYLAND_DISPLAY; }; /** * Detect if running under X11 - * @returns {boolean} + * @returns */ - const isX11 = () => { + const isX11 = (): boolean => { return !!process.env.DISPLAY && !isWayland(); }; /** * Get the current session type - * @returns {'x11' | 'wayland' | 'tty' | 'unknown'} + * @returns */ - const getSessionType = () => { + const getSessionType = (): LinuxSessionType => { const sessionType = process.env.XDG_SESSION_TYPE; if (sessionType === 'x11' || sessionType === 'wayland' || sessionType === 'tty') { return sessionType; } - if (isWayland()) return 'wayland'; - if (isX11()) return 'x11'; + if (isWayland()) { + return 'wayland'; + } + if (isX11()) { + return 'x11'; + } return 'unknown'; }; @@ -62,16 +75,18 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { /** * Wake monitor using X11 DPMS (works on X11 and often XWayland) - * @returns {Promise} Success status + * @returns Success status */ - const wakeMonitorX11 = async () => { - if (!$) return false; + const wakeMonitorX11 = async (): Promise => { + if (!shell) { + return false; + } try { - await $`xset dpms force on`.quiet(); + await shell`xset dpms force on`.quiet(); debugLog('wakeMonitor: X11 xset dpms force on succeeded'); return true; - } catch (e) { - debugLog(`wakeMonitor: X11 xset failed: ${e.message}`); + } catch (error) { + debugLog(`wakeMonitor: X11 xset failed: ${getErrorMessage(error)}`); return false; } }; @@ -79,16 +94,18 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { /** * Wake monitor using GNOME D-Bus (for GNOME on Wayland) * Triggers a brightness step which wakes the display - * @returns {Promise} Success status + * @returns Success status */ - const wakeMonitorGnomeDBus = async () => { - if (!$) return false; + const wakeMonitorGnomeDBus = async (): Promise => { + if (!shell) { + return false; + } try { - await $`gdbus call --session --dest org.gnome.SettingsDaemon.Power --object-path /org/gnome/SettingsDaemon/Power --method org.gnome.SettingsDaemon.Power.Screen.StepUp`.quiet(); + await shell`gdbus call --session --dest org.gnome.SettingsDaemon.Power --object-path /org/gnome/SettingsDaemon/Power --method org.gnome.SettingsDaemon.Power.Screen.StepUp`.quiet(); debugLog('wakeMonitor: GNOME D-Bus StepUp succeeded'); return true; - } catch (e) { - debugLog(`wakeMonitor: GNOME D-Bus failed: ${e.message}`); + } catch (error) { + debugLog(`wakeMonitor: GNOME D-Bus failed: ${getErrorMessage(error)}`); return false; } }; @@ -98,16 +115,20 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { * Tries multiple methods with graceful fallback: * 1. X11 xset (works on X11 and XWayland) * 2. GNOME D-Bus (works on GNOME Wayland) - * - * @returns {Promise} True if any method succeeded + * + * @returns True if any method succeeded */ - const wakeMonitor = async () => { + const wakeMonitor = async (): Promise => { // Try X11 method first (most compatible, works on XWayland too) - if (await wakeMonitorX11()) return true; - + if (await wakeMonitorX11()) { + return true; + } + // Try GNOME Wayland D-Bus method - if (await wakeMonitorGnomeDBus()) return true; - + if (await wakeMonitorGnomeDBus()) { + return true; + } + debugLog('wakeMonitor: all methods failed'); return false; }; @@ -118,73 +139,82 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { /** * Get current volume using PulseAudio/PipeWire (pactl) - * @returns {Promise} Volume percentage (0-100) or -1 if failed + * @returns Volume percentage (0-100) or -1 if failed */ - const getVolumePulse = async () => { - if (!$) return -1; + const getVolumePulse = async (): Promise => { + if (!shell) { + return -1; + } try { - const result = await $`pactl get-sink-volume @DEFAULT_SINK@`.quiet(); + const result = await shell`pactl get-sink-volume @DEFAULT_SINK@`.quiet(); const output = result.stdout?.toString() || ''; // Parse output like: "Volume: front-left: 65536 / 100% / 0.00 dB, ..." const match = output.match(/(\d+)%/); - if (match) { - const volume = parseInt(match[1], 10); + const percent = match?.[1]; + if (percent) { + const volume = parseInt(percent, 10); debugLog(`getVolume: pactl returned ${volume}%`); return volume; } - } catch (e) { - debugLog(`getVolume: pactl failed: ${e.message}`); + } catch (error) { + debugLog(`getVolume: pactl failed: ${getErrorMessage(error)}`); } return -1; }; /** * Set volume using PulseAudio/PipeWire (pactl) - * @param {number} volume - Volume percentage (0-100) - * @returns {Promise} Success status + * @param volume - Volume percentage (0-100) + * @returns Success status */ - const setVolumePulse = async (volume) => { - if (!$) return false; + const setVolumePulse = async (volume: number): Promise => { + if (!shell) { + return false; + } try { const clampedVolume = Math.max(0, Math.min(100, volume)); - await $`pactl set-sink-volume @DEFAULT_SINK@ ${clampedVolume}%`.quiet(); + await shell`pactl set-sink-volume @DEFAULT_SINK@ ${clampedVolume}%`.quiet(); debugLog(`setVolume: pactl set to ${clampedVolume}%`); return true; - } catch (e) { - debugLog(`setVolume: pactl failed: ${e.message}`); + } catch (error) { + debugLog(`setVolume: pactl failed: ${getErrorMessage(error)}`); return false; } }; /** * Unmute using PulseAudio/PipeWire (pactl) - * @returns {Promise} Success status + * @returns Success status */ - const unmutePulse = async () => { - if (!$) return false; + const unmutePulse = async (): Promise => { + if (!shell) { + return false; + } try { - await $`pactl set-sink-mute @DEFAULT_SINK@ 0`.quiet(); + await shell`pactl set-sink-mute @DEFAULT_SINK@ 0`.quiet(); debugLog('unmute: pactl succeeded'); return true; - } catch (e) { - debugLog(`unmute: pactl failed: ${e.message}`); + } catch (error) { + debugLog(`unmute: pactl failed: ${getErrorMessage(error)}`); return false; } }; /** * Check if muted using PulseAudio/PipeWire - * @returns {Promise} True if muted, false if not, null if failed + * @returns True if muted, false if not, null if failed */ - const isMutedPulse = async () => { - if (!$) return null; + const isMutedPulse = async (): Promise => { + if (!shell) { + return null; + } try { - const result = await $`pactl get-sink-mute @DEFAULT_SINK@`.quiet(); + const result = await shell`pactl get-sink-mute @DEFAULT_SINK@`.quiet(); const output = result.stdout?.toString() || ''; // Output: "Mute: yes" or "Mute: no" return /yes|true/i.test(output); - } catch (e) { - debugLog(`isMuted: pactl failed: ${e.message}`); + } catch (error) { + debugLog(`isMuted: pactl failed: ${getErrorMessage(error)}`); return null; } }; @@ -195,73 +225,82 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { /** * Get current volume using ALSA (amixer) - * @returns {Promise} Volume percentage (0-100) or -1 if failed + * @returns Volume percentage (0-100) or -1 if failed */ - const getVolumeAlsa = async () => { - if (!$) return -1; + const getVolumeAlsa = async (): Promise => { + if (!shell) { + return -1; + } try { - const result = await $`amixer get Master`.quiet(); + const result = await shell`amixer get Master`.quiet(); const output = result.stdout?.toString() || ''; // Parse output like: "Front Left: Playback 65536 [75%] [on]" const match = output.match(/\[(\d+)%\]/); - if (match) { - const volume = parseInt(match[1], 10); + const percent = match?.[1]; + if (percent) { + const volume = parseInt(percent, 10); debugLog(`getVolume: amixer returned ${volume}%`); return volume; } - } catch (e) { - debugLog(`getVolume: amixer failed: ${e.message}`); + } catch (error) { + debugLog(`getVolume: amixer failed: ${getErrorMessage(error)}`); } return -1; }; /** * Set volume using ALSA (amixer) - * @param {number} volume - Volume percentage (0-100) - * @returns {Promise} Success status + * @param volume - Volume percentage (0-100) + * @returns Success status */ - const setVolumeAlsa = async (volume) => { - if (!$) return false; + const setVolumeAlsa = async (volume: number): Promise => { + if (!shell) { + return false; + } try { const clampedVolume = Math.max(0, Math.min(100, volume)); - await $`amixer set Master ${clampedVolume}%`.quiet(); + await shell`amixer set Master ${clampedVolume}%`.quiet(); debugLog(`setVolume: amixer set to ${clampedVolume}%`); return true; - } catch (e) { - debugLog(`setVolume: amixer failed: ${e.message}`); + } catch (error) { + debugLog(`setVolume: amixer failed: ${getErrorMessage(error)}`); return false; } }; /** * Unmute using ALSA (amixer) - * @returns {Promise} Success status + * @returns Success status */ - const unmuteAlsa = async () => { - if (!$) return false; + const unmuteAlsa = async (): Promise => { + if (!shell) { + return false; + } try { - await $`amixer set Master unmute`.quiet(); + await shell`amixer set Master unmute`.quiet(); debugLog('unmute: amixer succeeded'); return true; - } catch (e) { - debugLog(`unmute: amixer failed: ${e.message}`); + } catch (error) { + debugLog(`unmute: amixer failed: ${getErrorMessage(error)}`); return false; } }; /** * Check if muted using ALSA - * @returns {Promise} True if muted, false if not, null if failed + * @returns True if muted, false if not, null if failed */ - const isMutedAlsa = async () => { - if (!$) return null; + const isMutedAlsa = async (): Promise => { + if (!shell) { + return null; + } try { - const result = await $`amixer get Master`.quiet(); + const result = await shell`amixer get Master`.quiet(); const output = result.stdout?.toString() || ''; // Look for [off] or [mute] in output return /\[off\]|\[mute\]/i.test(output); - } catch (e) { - debugLog(`isMuted: amixer failed: ${e.message}`); + } catch (error) { + debugLog(`isMuted: amixer failed: ${getErrorMessage(error)}`); return null; } }; @@ -273,13 +312,15 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { /** * Get current system volume * Tries PulseAudio first, then falls back to ALSA - * @returns {Promise} Volume percentage (0-100) or -1 if failed + * @returns Volume percentage (0-100) or -1 if failed */ - const getCurrentVolume = async () => { + const getCurrentVolume = async (): Promise => { // Try PulseAudio/PipeWire first (most common on desktop Linux) let volume = await getVolumePulse(); - if (volume >= 0) return volume; - + if (volume >= 0) { + return volume; + } + // Fallback to ALSA volume = await getVolumeAlsa(); return volume; @@ -288,13 +329,15 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { /** * Set system volume * Tries PulseAudio first, then falls back to ALSA - * @param {number} volume - Volume percentage (0-100) - * @returns {Promise} Success status + * @param volume - Volume percentage (0-100) + * @returns Success status */ - const setVolume = async (volume) => { + const setVolume = async (volume: number): Promise => { // Try PulseAudio/PipeWire first - if (await setVolumePulse(volume)) return true; - + if (await setVolumePulse(volume)) { + return true; + } + // Fallback to ALSA return await setVolumeAlsa(volume); }; @@ -302,12 +345,14 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { /** * Unmute system audio * Tries PulseAudio first, then falls back to ALSA - * @returns {Promise} Success status + * @returns Success status */ - const unmute = async () => { + const unmute = async (): Promise => { // Try PulseAudio/PipeWire first - if (await unmutePulse()) return true; - + if (await unmutePulse()) { + return true; + } + // Fallback to ALSA return await unmuteAlsa(); }; @@ -315,23 +360,26 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { /** * Check if system audio is muted * Tries PulseAudio first, then falls back to ALSA - * @returns {Promise} True if muted, false if not, null if detection failed + * @returns True if muted, false if not, null if detection failed */ - const isMuted = async () => { + const isMuted = async (): Promise => { // Try PulseAudio/PipeWire first let muted = await isMutedPulse(); - if (muted !== null) return muted; - + if (muted !== null) { + return muted; + } + // Fallback to ALSA - return await isMutedAlsa(); + muted = await isMutedAlsa(); + return muted; }; /** * Force volume to maximum (unmute + set to 100%) * Used to ensure notifications are audible - * @returns {Promise} Success status + * @returns Success status */ - const forceVolume = async () => { + const forceVolume = async (): Promise => { const unmuted = await unmute(); const volumeSet = await setVolume(100); return unmuted || volumeSet; @@ -339,24 +387,24 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { /** * Force volume if below threshold - * @param {number} threshold - Minimum volume threshold (0-100) - * @returns {Promise} True if volume was forced, false if already adequate + * @param threshold - Minimum volume threshold (0-100) + * @returns True if volume was forced, false if already adequate */ - const forceVolumeIfNeeded = async (threshold = 50) => { + const forceVolumeIfNeeded = async (threshold = 50): Promise => { const currentVolume = await getCurrentVolume(); - + // If we couldn't detect volume, force it to be safe if (currentVolume < 0) { debugLog('forceVolumeIfNeeded: could not detect volume, forcing'); return await forceVolume(); } - + // Check if already above threshold if (currentVolume >= threshold) { debugLog(`forceVolumeIfNeeded: volume ${currentVolume}% >= ${threshold}%, no action needed`); return false; } - + // Force volume up debugLog(`forceVolumeIfNeeded: volume ${currentVolume}% < ${threshold}%, forcing to 100%`); return await forceVolume(); @@ -368,17 +416,19 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { /** * Play an audio file using PulseAudio (paplay) - * @param {string} filePath - Path to audio file - * @returns {Promise} Success status + * @param filePath - Path to audio file + * @returns Success status */ - const playAudioPulse = async (filePath) => { - if (!$) return false; + const playAudioPulse = async (filePath: string): Promise => { + if (!shell) { + return false; + } try { - await $`paplay ${filePath}`.quiet(); + await shell`paplay ${filePath}`.quiet(); debugLog(`playAudio: paplay succeeded for ${filePath}`); return true; - } catch (e) { - debugLog(`playAudio: paplay failed: ${e.message}`); + } catch (error) { + debugLog(`playAudio: paplay failed: ${getErrorMessage(error)}`); return false; } }; @@ -386,17 +436,19 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { /** * Play an audio file using ALSA (aplay) * Note: aplay only supports WAV files natively - * @param {string} filePath - Path to audio file - * @returns {Promise} Success status + * @param filePath - Path to audio file + * @returns Success status */ - const playAudioAlsa = async (filePath) => { - if (!$) return false; + const playAudioAlsa = async (filePath: string): Promise => { + if (!shell) { + return false; + } try { - await $`aplay ${filePath}`.quiet(); + await shell`aplay ${filePath}`.quiet(); debugLog(`playAudio: aplay succeeded for ${filePath}`); return true; - } catch (e) { - debugLog(`playAudio: aplay failed: ${e.message}`); + } catch (error) { + debugLog(`playAudio: aplay failed: ${getErrorMessage(error)}`); return false; } }; @@ -404,18 +456,22 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { /** * Play an audio file * Tries PulseAudio (paplay) first, then falls back to ALSA (aplay) - * @param {string} filePath - Path to audio file - * @param {number} loops - Number of times to play (default: 1) - * @returns {Promise} Success status + * @param filePath - Path to audio file + * @param loops - Number of times to play (default: 1) + * @returns Success status */ - const playAudioFile = async (filePath, loops = 1) => { + const playAudioFile = async (filePath: string, loops = 1): Promise => { for (let i = 0; i < loops; i++) { // Try PulseAudio first (supports more formats including MP3) - if (await playAudioPulse(filePath)) continue; - + if (await playAudioPulse(filePath)) { + continue; + } + // Fallback to ALSA - if (await playAudioAlsa(filePath)) continue; - + if (await playAudioAlsa(filePath)) { + continue; + } + // Both failed debugLog(`playAudioFile: all methods failed for ${filePath}`); return false; @@ -432,12 +488,12 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { isWayland, isX11, getSessionType, - + // Wake monitor wakeMonitor, wakeMonitorX11, wakeMonitorGnomeDBus, - + // Volume control (unified) getCurrentVolume, setVolume, @@ -445,7 +501,7 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { isMuted, forceVolume, forceVolumeIfNeeded, - + // Volume control (specific backends) pulse: { getVolume: getVolumePulse, @@ -459,7 +515,7 @@ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => { unmute: unmuteAlsa, isMuted: isMutedAlsa, }, - + // Audio playback playAudioFile, playAudioPulse, diff --git a/util/per-project-sound.js b/src/util/per-project-sound.ts similarity index 65% rename from util/per-project-sound.js rename to src/util/per-project-sound.ts index c1c2ab4..b238c73 100644 --- a/util/per-project-sound.js +++ b/src/util/per-project-sound.ts @@ -1,25 +1,35 @@ import crypto from 'crypto'; -import path from 'path'; import fs from 'fs'; import os from 'os'; +import path from 'path'; + +import type { PluginConfig } from '../types/config.js'; +import type { Project } from '../types/opencode-sdk.js'; /** * Per-Project Sound Module - * + * * Provides logic for assigning unique sounds to different projects. * Hashes project directory + seed to pick a consistent sound from assets. */ -const projectSoundCache = new Map(); +const projectSoundCache = new Map(); + +const getErrorMessage = (error: unknown): string => { + const maybeError = error as { message?: unknown }; + return String(maybeError?.message); +}; /** * Internal debug logger - * @param {string} message - * @param {object} config + * @param message + * @param config */ -const debugLog = (message, config) => { - if (!config || !config.debugLog) return; - +const debugLog = (message: string, config?: PluginConfig | null): void => { + if (!config || !config.debugLog) { + return; + } + const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode'); const logsDir = path.join(configDir, 'logs'); const logFile = path.join(logsDir, 'smart-voice-notify-debug.log'); @@ -30,27 +40,30 @@ const debugLog = (message, config) => { } const timestamp = new Date().toISOString(); fs.appendFileSync(logFile, `[${timestamp}] [per-project-sound] ${message}\n`); - } catch (e) { + } catch { // Silently fail - logging is optional } }; /** * Get a unique sound for a project by hashing its path. - * @param {object} project - The project object (should contain directory) - * @param {object} config - Plugin configuration - * @returns {string | null} Relative path to the project-specific sound, or null if disabled/unavailable + * @param project - The project object (should contain directory) + * @param config - Plugin configuration + * @returns Relative path to the project-specific sound, or null if disabled/unavailable */ -export const getProjectSound = (project, config) => { +export const getProjectSound = ( + project: Project | null | undefined, + config: PluginConfig | null | undefined, +): string | null => { if (!config || !config.perProjectSounds || !project?.directory) { return null; } const projectPath = project.directory; - + // Use cache to ensure consistency within session if (projectSoundCache.has(projectPath)) { - const cachedSound = projectSoundCache.get(projectPath); + const cachedSound = projectSoundCache.get(projectPath)!; debugLog(`Returning cached sound for project: ${projectPath} -> ${cachedSound}`, config); return cachedSound; } @@ -60,19 +73,19 @@ export const getProjectSound = (project, config) => { const seed = config.projectSoundSeed || 0; // We use MD5 because it's fast and sufficient for this purpose const hash = crypto.createHash('md5').update(projectPath + seed).digest('hex'); - + // Map hash to 1-6 (opencode-notificator pattern) // Using first 8 chars of hash for a stable number const soundIndex = (parseInt(hash.substring(0, 8), 16) % 6) + 1; const soundFile = `assets/ding${soundIndex}.mp3`; - + debugLog(`Assigned new sound for project: ${projectPath} (seed: ${seed}) -> ${soundFile}`, config); - + // Cache and return projectSoundCache.set(projectPath, soundFile); return soundFile; - } catch (e) { - debugLog(`Error assigning project sound: ${e.message}`, config); + } catch (error) { + debugLog(`Error assigning project sound: ${getErrorMessage(error)}`, config); return null; } }; @@ -80,11 +93,11 @@ export const getProjectSound = (project, config) => { /** * Clear the project sound cache (used for testing) */ -export const clearProjectSoundCache = () => { +export const clearProjectSoundCache = (): void => { projectSoundCache.clear(); }; export default { getProjectSound, - clearProjectSoundCache + clearProjectSoundCache, }; diff --git a/util/sound-theme.js b/src/util/sound-theme.ts similarity index 55% rename from util/sound-theme.js rename to src/util/sound-theme.ts index 914461a..0e7a21f 100644 --- a/util/sound-theme.js +++ b/src/util/sound-theme.ts @@ -1,24 +1,28 @@ import fs from 'fs'; -import path from 'path'; import os from 'os'; +import path from 'path'; + +import type { PluginConfig } from '../types/config.js'; /** * Sound Theme Module - * + * * Provides functionality for themed sound packs. * Supports directory structure with idle/, permission/, error/, and question/ subdirectories. */ -const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.flac']; +const AUDIO_EXTENSIONS: readonly string[] = ['.mp3', '.wav', '.ogg', '.m4a', '.flac']; /** * Internal debug logger - * @param {string} message - * @param {object} config + * @param message + * @param config */ -const debugLog = (message, config) => { - if (!config || !config.debugLog) return; - +const debugLog = (message: string, config?: PluginConfig | null): void => { + if (!config || !config.debugLog) { + return; + } + const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode'); const logsDir = path.join(configDir, 'logs'); const logFile = path.join(logsDir, 'smart-voice-notify-debug.log'); @@ -29,19 +33,21 @@ const debugLog = (message, config) => { } const timestamp = new Date().toISOString(); fs.appendFileSync(logFile, `[${timestamp}] [sound-theme] ${message}\n`); - } catch (e) { + } catch { // Silently fail - logging is optional } }; /** * List all audio files in a theme subdirectory - * @param {string} themeDir - Root theme directory - * @param {string} eventType - Subdirectory name (idle, permission, error, question) - * @returns {string[]} Absolute paths to audio files + * @param themeDir - Root theme directory + * @param eventType - Subdirectory name (idle, permission, error, question) + * @returns Absolute paths to audio files */ -export const listSoundsInTheme = (themeDir, eventType) => { - if (!themeDir) return []; +export const listSoundsInTheme = (themeDir: string, eventType: string): string[] => { + if (!themeDir) { + return []; + } const subDir = path.join(themeDir, eventType); if (!fs.existsSync(subDir) || !fs.statSync(subDir).isDirectory()) { @@ -49,24 +55,27 @@ export const listSoundsInTheme = (themeDir, eventType) => { } try { - return fs.readdirSync(subDir) - .filter(file => AUDIO_EXTENSIONS.includes(path.extname(file).toLowerCase())) + return fs + .readdirSync(subDir) + .filter((file) => AUDIO_EXTENSIONS.includes(path.extname(file).toLowerCase())) .sort() // Sort alphabetically for consistent cross-platform behavior - .map(file => path.join(subDir, file)) - .filter(filePath => fs.statSync(filePath).isFile()); - } catch (error) { + .map((file) => path.join(subDir, file)) + .filter((filePath) => fs.statSync(filePath).isFile()); + } catch { return []; } }; /** * Pick a sound for the given event type from the theme directory - * @param {string} eventType - Type of event (idle, permission, error, question) - * @param {object} config - Plugin configuration - * @returns {string|null} Path to the selected sound, or null if theme not available + * @param eventType - Type of event (idle, permission, error, question) + * @param config - Plugin configuration + * @returns Path to the selected sound, or null if theme not available */ -export const pickThemeSound = (eventType, config) => { - if (!config.soundThemeDir) return null; +export const pickThemeSound = (eventType: string, config: PluginConfig): string | null => { + if (!config.soundThemeDir) { + return null; + } // Resolve absolute path if relative let themeDir = config.soundThemeDir; @@ -86,13 +95,13 @@ export const pickThemeSound = (eventType, config) => { return null; } - let selected; + let selected: string; if (config.randomizeSoundFromTheme) { const randomIndex = Math.floor(Math.random() * sounds.length); - selected = sounds[randomIndex]; + selected = sounds[randomIndex]!; debugLog(`Randomly selected sound for '${eventType}': ${selected} (from ${sounds.length} files)`, config); } else { - selected = sounds[0]; + selected = sounds[0]!; debugLog(`Selected first sound for '${eventType}': ${selected}`, config); } @@ -101,23 +110,28 @@ export const pickThemeSound = (eventType, config) => { /** * Pick a random sound from a directory - * @param {string} dirPath - Directory path - * @returns {string|null} Path to a random audio file + * @param dirPath - Directory path + * @returns Path to a random audio file */ -export const pickRandomSound = (dirPath) => { - if (!dirPath || !fs.existsSync(dirPath)) return null; +export const pickRandomSound = (dirPath: string): string | null => { + if (!dirPath || !fs.existsSync(dirPath)) { + return null; + } try { - const files = fs.readdirSync(dirPath) - .filter(file => AUDIO_EXTENSIONS.includes(path.extname(file).toLowerCase())) - .map(file => path.join(dirPath, file)) - .filter(filePath => fs.statSync(filePath).isFile()); - - if (files.length === 0) return null; + const files = fs + .readdirSync(dirPath) + .filter((file) => AUDIO_EXTENSIONS.includes(path.extname(file).toLowerCase())) + .map((file) => path.join(dirPath, file)) + .filter((filePath) => fs.statSync(filePath).isFile()); + + if (files.length === 0) { + return null; + } const randomIndex = Math.floor(Math.random() * files.length); - return files[randomIndex]; - } catch (error) { + return files[randomIndex]!; + } catch { return null; } }; @@ -125,5 +139,5 @@ export const pickRandomSound = (dirPath) => { export default { listSoundsInTheme, pickThemeSound, - pickRandomSound + pickRandomSound, }; diff --git a/util/tts.js b/src/util/tts.ts similarity index 73% rename from util/tts.js rename to src/util/tts.ts index 5314ebe..5850cf1 100644 --- a/util/tts.js +++ b/src/util/tts.ts @@ -1,6 +1,12 @@ -import path from 'path'; -import os from 'os'; import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import type { PluginConfig } from '../types/config.js'; +import type { LinuxPlatformAPI } from '../types/linux.js'; +import type { OpenCodeClient, ShellRunner } from '../types/opencode-sdk.js'; +import type { SpeakOptions, TTSAPI, TTSFactoryParams } from '../types/tts.js'; + import { loadConfig } from './config.js'; import { createLinuxPlatform } from './linux.js'; @@ -8,18 +14,55 @@ const platform = os.platform(); // Remove module-level configDir constant that caches process.env prematurely // const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode'); +type ToastVariant = 'info' | 'success' | 'warning' | 'error'; + +interface ElevenLabsErrorLike { + statusCode?: number; + message?: string; +} + +const getErrorMessage = (error: unknown): string => { + const maybeError = error as { message?: unknown }; + return String(maybeError?.message ?? error); +}; + +const outputToString = (value: unknown): string => { + if (typeof value === 'string') { + return value; + } + if (value instanceof Uint8Array) { + return Buffer.from(value).toString(); + } + if (value === undefined || value === null) { + return ''; + } + return String(value); +}; + +const toBufferChunk = (chunk: unknown): Buffer => { + if (Buffer.isBuffer(chunk)) { + return chunk; + } + if (chunk instanceof Uint8Array) { + return Buffer.from(chunk); + } + if (chunk instanceof ArrayBuffer) { + return Buffer.from(chunk); + } + return Buffer.from(String(chunk)); +}; + /** * Gets the current OpenCode config directory - * @returns {string} + * @returns */ -const getConfigDir = () => process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode'); - +const getConfigDir = (): string => process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode'); /** * Loads the TTS configuration (shared with the notification plugin) - * @returns {object} + * @returns */ -export const getTTSConfig = () => { +export const getTTSConfig = (): PluginConfig => { return loadConfig('smart-voice-notify', { ttsEngine: 'elevenlabs', enableTTS: true, @@ -36,7 +79,7 @@ export const getTTSConfig = () => { sapiRate: -1, sapiPitch: 'medium', sapiVolume: 'loud', - + // OpenAI-compatible TTS settings openaiTtsEndpoint: '', openaiTtsApiKey: '', @@ -44,7 +87,7 @@ export const getTTSConfig = () => { openaiTtsVoice: 'alloy', openaiTtsFormat: 'mp3', openaiTtsSpeed: 1.0, - + // ============================================================ // NOTIFICATION MODE & TTS REMINDER SETTINGS // ============================================================ @@ -53,21 +96,21 @@ export const getTTSConfig = () => { // 'both' - Play sound AND speak TTS immediately // 'sound-only' - Only play sound, no TTS at all notificationMode: 'sound-first', - + // Enable TTS reminder if user doesn't respond after sound notification enableTTSReminder: true, - + // Delay in seconds before TTS reminder (if user hasn't responded) // Can be set globally or per-notification type ttsReminderDelaySeconds: 30, idleReminderDelaySeconds: 30, permissionReminderDelaySeconds: 20, - + // Follow-up reminders (if user still doesn't respond after first TTS) enableFollowUpReminders: true, maxFollowUpReminders: 3, - reminderBackoffMultiplier: 1.5, // Each follow-up waits longer (30s, 45s, 67.5s) - + reminderBackoffMultiplier: 1.5, // Each follow-up waits longer (30s, 45s, 67.5s) + // ============================================================ // TTS MESSAGE VARIETY (Initial notifications - randomly selected) // ============================================================ @@ -77,7 +120,7 @@ export const getTTSConfig = () => { 'Hey there! I finished working on your request.', 'Task complete! Ready for your review whenever you are.', 'Good news! Everything is done and ready for you.', - 'Finished! Let me know if you need anything else.' + 'Finished! Let me know if you need anything else.', ], // Messages for permission requests permissionTTSMessages: [ @@ -85,7 +128,7 @@ export const getTTSConfig = () => { 'Hey! Quick approval needed to proceed with the task.', 'Heads up! There is a permission request waiting for you.', 'Excuse me! I need your authorization before I can continue.', - 'Permission required! Please review and approve when ready.' + 'Permission required! Please review and approve when ready.', ], // Messages for MULTIPLE permission requests (use {count} placeholder) permissionTTSMessagesMultiple: [ @@ -93,9 +136,9 @@ export const getTTSConfig = () => { 'Hey! {count} permissions need your approval to continue.', 'Heads up! You have {count} pending permission requests.', 'Excuse me! I need your authorization for {count} different actions.', - '{count} permissions required! Please review and approve when ready.' + '{count} permissions required! Please review and approve when ready.', ], - + // ============================================================ // TTS REMINDER MESSAGES (More urgent/personalized - used after delay) // ============================================================ @@ -105,7 +148,7 @@ export const getTTSConfig = () => { 'Just a gentle reminder - I finished your request a while ago!', 'Hello? I completed your task. Please take a look when you can.', 'Still waiting for you! The work is done and ready for review.', - 'Knock knock! Your completed task is patiently waiting for you.' + 'Knock knock! Your completed task is patiently waiting for you.', ], // Reminder messages when permission still needed permissionReminderTTSMessages: [ @@ -113,7 +156,7 @@ export const getTTSConfig = () => { 'Reminder: There is a pending permission request. I cannot proceed without you.', 'Hello? I am waiting for your approval. This is getting urgent!', 'Please check your screen! I really need your permission to move forward.', - 'Still waiting for authorization! The task is on hold until you respond.' + 'Still waiting for authorization! The task is on hold until you respond.', ], // Reminder messages for MULTIPLE permissions (use {count} placeholder) permissionReminderTTSMessagesMultiple: [ @@ -121,12 +164,12 @@ export const getTTSConfig = () => { 'Reminder: There are {count} pending permission requests. I cannot proceed without you.', 'Hello? I am waiting for your approval on {count} items. This is getting urgent!', 'Please check your screen! {count} permissions are waiting for your response.', - 'Still waiting for authorization on {count} requests! The task is on hold.' + 'Still waiting for authorization on {count} requests! The task is on hold.', ], - + // Permission batch window (ms) - how long to wait for more permissions before notifying permissionBatchWindowMs: 800, - + // ============================================================ // QUESTION TOOL MESSAGES (SDK v1.1.7+ - Agent asking user questions) // ============================================================ @@ -136,7 +179,7 @@ export const getTTSConfig = () => { 'Attention! I need your input to continue.', 'Quick question! Please take a look when you have a moment.', 'I need some clarification. Could you please respond?', - 'Question time! Your input is needed to proceed.' + 'Question time! Your input is needed to proceed.', ], // Messages for MULTIPLE questions (use {count} placeholder) questionTTSMessagesMultiple: [ @@ -144,7 +187,7 @@ export const getTTSConfig = () => { 'Attention! I need your input on {count} items to continue.', '{count} questions need your attention. Please take a look!', 'I need some clarifications. There are {count} questions waiting for you.', - 'Question time! {count} questions need your response to proceed.' + 'Question time! {count} questions need your response to proceed.', ], // Reminder messages for questions questionReminderTTSMessages: [ @@ -152,7 +195,7 @@ export const getTTSConfig = () => { 'Reminder: There is a question waiting for your response.', 'Hello? I need your input to continue. Please respond when you can.', 'Still waiting for your answer! The task is on hold.', - 'Your input is needed! Please check the pending question.' + 'Your input is needed! Please check the pending question.', ], // Reminder messages for MULTIPLE questions (use {count} placeholder) questionReminderTTSMessagesMultiple: [ @@ -160,20 +203,20 @@ export const getTTSConfig = () => { 'Reminder: There are {count} questions waiting for your response.', 'Hello? I need your input on {count} items. Please respond when you can.', 'Still waiting for your answers on {count} questions! The task is on hold.', - 'Your input is needed! {count} questions are pending your response.' + 'Your input is needed! {count} questions are pending your response.', ], // Question reminder delay (seconds) - slightly less urgent than permissions questionReminderDelaySeconds: 25, // Question batch window (ms) - how long to wait for more questions before notifying questionBatchWindowMs: 800, - + // ============================================================ // SOUND FILES (Used for immediate notifications) // ============================================================ idleSound: 'assets/Soft-high-tech-notification-sound-effect.mp3', permissionSound: 'assets/Machine-alert-beep-sound-effect.mp3', questionSound: 'assets/Machine-alert-beep-sound-effect.mp3', - + // ============================================================ // GENERAL SETTINGS // ============================================================ @@ -183,7 +226,7 @@ export const getTTSConfig = () => { enableToast: true, volumeThreshold: 50, idleThresholdSeconds: 30, - debugLog: false + debugLog: false, }); }; @@ -191,57 +234,59 @@ let elevenLabsQuotaExceeded = false; /** * Creates a TTS utility instance - * @param {object} params - { $, client } - * @returns {object} TTS API + * @param params - { $, client } + * @returns TTS API */ -export const createTTS = ({ $, client }) => { +export const createTTS = ({ $, client }: TTSFactoryParams): TTSAPI => { + const shell: ShellRunner | undefined = $; + const opencodeClient: OpenCodeClient | undefined = client; const config = getTTSConfig(); const configDir = getConfigDir(); const logsDir = path.join(configDir, 'logs'); const logFile = path.join(logsDir, 'smart-voice-notify-debug.log'); - + // Ensure logs directory exists if debug logging is enabled if (config.debugLog && !fs.existsSync(logsDir)) { try { fs.mkdirSync(logsDir, { recursive: true }); - } catch (e) { + } catch { // Silently fail - logging is optional } } // Debug logging function (defined early so it can be passed to Linux platform) - const debugLog = (message) => { + const debugLog = (message: string): void => { if (!config.debugLog) return; try { const timestamp = new Date().toISOString(); fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`); - } catch (e) {} + } catch {} }; // Initialize Linux platform utilities (only used on Linux) - const linux = platform === 'linux' ? createLinuxPlatform({ $, debugLog }) : null; + const linux: LinuxPlatformAPI | null = platform === 'linux' ? createLinuxPlatform({ $: shell, debugLog }) : null; - const showToast = async (message, variant = 'info') => { + const showToast = async (message: string, variant: ToastVariant = 'info'): Promise => { if (!config.enableToast) return; try { - if (typeof client?.tui?.showToast === 'function') { - await client.tui.showToast({ + if (typeof opencodeClient?.tui?.showToast === 'function') { + await opencodeClient.tui.showToast({ body: { - message: message, - variant: variant, - duration: 6000 - } + message, + variant, + duration: 6000, + }, }); } - } catch (e) {} + } catch {} }; /** * Play an audio file using system media player */ - const playAudioFile = async (filePath, loops = 1) => { - if (!$) { + const playAudioFile = async (filePath: string, loops = 1): Promise => { + if (!shell) { debugLog('playAudioFile: shell runner ($) not available'); return; } @@ -261,10 +306,10 @@ export const createTTS = ({ $, client }) => { } $player.Close() `; - await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet(); + await shell`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet(); } else if (platform === 'darwin') { for (let i = 0; i < loops; i++) { - await $`afplay ${filePath}`.quiet(); + await shell`afplay ${filePath}`.quiet(); } } else if (platform === 'linux' && linux) { // Use the Linux platform module for audio playback @@ -273,21 +318,21 @@ export const createTTS = ({ $, client }) => { // Generic fallback for other Unix-like systems for (let i = 0; i < loops; i++) { try { - await $`paplay ${filePath}`.quiet(); + await shell`paplay ${filePath}`.quiet(); } catch { - await $`aplay ${filePath}`.quiet(); + await shell`aplay ${filePath}`.quiet(); } } } - } catch (e) { - debugLog(`playAudioFile error: ${e.message}`); + } catch (error) { + debugLog(`playAudioFile error: ${getErrorMessage(error)}`); } }; /** * ElevenLabs Engine (Online, High Quality, Anime-like voices) */ - const speakWithElevenLabs = async (text) => { + const speakWithElevenLabs = async (text: string): Promise => { if (elevenLabsQuotaExceeded) return false; if (!config.elevenLabsApiKey) { @@ -298,41 +343,49 @@ export const createTTS = ({ $, client }) => { try { const { ElevenLabsClient } = await import('@elevenlabs/elevenlabs-js'); const elClient = new ElevenLabsClient({ apiKey: config.elevenLabsApiKey }); - - const audio = await elClient.textToSpeech.convert(config.elevenLabsVoiceId || 'cgSgspJ2msm6clMCkdW9', { - text: text, + + const elevenLabsPayload = { + text, model_id: config.elevenLabsModel || 'eleven_turbo_v2_5', voice_settings: { stability: config.elevenLabsStability ?? 0.5, similarity_boost: config.elevenLabsSimilarity ?? 0.75, style: config.elevenLabsStyle ?? 0.5, - use_speaker_boost: true - } - }); - + use_speaker_boost: true, + }, + } as unknown as Parameters[1]; + + const audio = await elClient.textToSpeech.convert(config.elevenLabsVoiceId || 'cgSgspJ2msm6clMCkdW9', elevenLabsPayload); + const tempFile = path.join(os.tmpdir(), `opencode-tts-${Date.now()}.mp3`); - const chunks = []; - for await (const chunk of audio) { chunks.push(chunk); } + const chunks: Buffer[] = []; + for await (const chunk of audio as AsyncIterable) { + chunks.push(toBufferChunk(chunk)); + } fs.writeFileSync(tempFile, Buffer.concat(chunks)); - + await playAudioFile(tempFile); - try { fs.unlinkSync(tempFile); } catch (e) {} + try { + fs.unlinkSync(tempFile); + } catch {} return true; - } catch (e) { - debugLog(`speakWithElevenLabs error: ${e?.message || String(e) || 'Unknown error'}`); - + } catch (error) { + debugLog(`speakWithElevenLabs error: ${getErrorMessage(error) || String(error) || 'Unknown error'}`); + // Handle quota exceeded (401 specifically, or specific error message) - const isQuotaError = - e.statusCode === 401 || - e.message?.includes('401') || - e.message?.toLowerCase().includes('quota_exceeded') || - e.message?.toLowerCase().includes('quota exceeded'); + const elevenLabsError = error as ElevenLabsErrorLike; + const errorMessage = elevenLabsError.message ?? ''; + const isQuotaError = + elevenLabsError.statusCode === 401 || + errorMessage.includes('401') || + errorMessage.toLowerCase().includes('quota_exceeded') || + errorMessage.toLowerCase().includes('quota exceeded'); if (isQuotaError) { elevenLabsQuotaExceeded = true; - await showToast("⚠️ ElevenLabs quota exceeded! Switching to Edge TTS for this session.", "error"); + await showToast('⚠️ ElevenLabs quota exceeded! Switching to Edge TTS for this session.', 'error'); } - + return false; } }; @@ -342,49 +395,55 @@ export const createTTS = ({ $, client }) => { * Uses Python edge-tts package via command line as it's more reliable than Node.js WebSocket libraries. * Fallback: tries msedge-tts npm package if Python edge-tts is not available. */ - const speakWithEdgeTTS = async (text) => { + const speakWithEdgeTTS = async (text: string): Promise => { const voice = config.edgeVoice || 'en-US-JennyNeural'; const pitch = config.edgePitch || '+0Hz'; const rate = config.edgeRate || '+10%'; const volume = config.edgeVolume || '+0%'; const tempFile = path.join(os.tmpdir(), `opencode-edge-tts-${Date.now()}.mp3`); - + // Escape text for shell (replace quotes with escaped quotes) const escapedText = text.replace(/"/g, '\\"'); - + // Try Python edge-tts first (more reliable due to aiohttp WebSocket handling) - if ($) { + if (shell) { try { // Use proper template literal syntax with individual arguments - await $`edge-tts --voice ${voice} --rate ${rate} --volume ${volume} --pitch ${pitch} --text ${escapedText} --write-media ${tempFile}`.quiet().nothrow(); - + await shell`edge-tts --voice ${voice} --rate ${rate} --volume ${volume} --pitch ${pitch} --text ${escapedText} --write-media ${tempFile}` + .quiet() + .nothrow(); + if (fs.existsSync(tempFile)) { await playAudioFile(tempFile); - try { fs.unlinkSync(tempFile); } catch (e) {} + try { + fs.unlinkSync(tempFile); + } catch {} debugLog('speakWithEdgeTTS: success via Python edge-tts CLI'); return true; } - } catch (e) { - debugLog(`speakWithEdgeTTS: Python CLI failed: ${e?.message || 'unknown'}, trying npm package...`); + } catch (error) { + debugLog(`speakWithEdgeTTS: Python CLI failed: ${getErrorMessage(error) || 'unknown'}, trying npm package...`); // Fall through to try npm package } } - + // Fallback to msedge-tts npm package try { const { MsEdgeTTS, OUTPUT_FORMAT } = await import('msedge-tts'); const tts = new MsEdgeTTS(); - + await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3); - + const { audioFilePath } = await tts.toFile(os.tmpdir(), text, { pitch, rate, volume }); - + await playAudioFile(audioFilePath); - try { fs.unlinkSync(audioFilePath); } catch (e) {} + try { + fs.unlinkSync(audioFilePath); + } catch {} debugLog('speakWithEdgeTTS: success via msedge-tts npm package'); return true; - } catch (e) { - debugLog(`speakWithEdgeTTS error: ${e?.message || String(e) || 'Unknown error'}`); + } catch (error) { + debugLog(`speakWithEdgeTTS error: ${getErrorMessage(error) || String(error) || 'Unknown error'}`); return false; } }; @@ -392,24 +451,29 @@ export const createTTS = ({ $, client }) => { /** * Windows SAPI Engine (Offline, Built-in) */ - const speakWithSAPI = async (text) => { + const speakWithSAPI = async (text: string): Promise => { if (platform !== 'win32') { debugLog('speakWithSAPI: skipped (not Windows)'); return false; } - if (!$) { + if (!shell) { debugLog('speakWithSAPI: skipped (shell helper $ not available)'); return false; } const scriptPath = path.join(os.tmpdir(), `opencode-sapi-${Date.now()}.ps1`); try { - const escapedText = text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + const escapedText = text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); const voice = config.sapiVoice || 'Microsoft Zira Desktop'; const rate = Math.max(-10, Math.min(10, config.sapiRate || -1)); const pitch = config.sapiPitch || 'medium'; const volume = config.sapiVolume || 'loud'; const ratePercent = rate >= 0 ? `+${rate * 10}%` : `${rate * 5}%`; - + const ssml = ` @@ -418,7 +482,7 @@ export const createTTS = ({ $, client }) => { `; - + const scriptContent = ` Add-Type -AssemblyName System.Speech $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer @@ -437,31 +501,33 @@ ${ssml} } `; fs.writeFileSync(scriptPath, scriptContent, 'utf-8'); - const result = await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${scriptPath}`.nothrow().quiet(); - + const result = await shell`powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${scriptPath}`.nothrow().quiet(); + if (result.exitCode !== 0) { - debugLog(`speakWithSAPI failed with code ${result.exitCode}: ${result.stderr}`); + debugLog(`speakWithSAPI failed with code ${result.exitCode}: ${outputToString(result.stderr)}`); return false; } return true; - } catch (e) { - debugLog(`speakWithSAPI error: ${e?.message || String(e) || 'Unknown error'}`); + } catch (error) { + debugLog(`speakWithSAPI error: ${getErrorMessage(error) || String(error) || 'Unknown error'}`); return false; } finally { - try { if (fs.existsSync(scriptPath)) fs.unlinkSync(scriptPath); } catch (e) {} + try { + if (fs.existsSync(scriptPath)) fs.unlinkSync(scriptPath); + } catch {} } }; /** * macOS Say Engine */ - const speakWithSay = async (text) => { - if (platform !== 'darwin' || !$) return false; + const speakWithSay = async (text: string): Promise => { + if (platform !== 'darwin' || !shell) return false; try { - await $`say ${text}`.quiet(); + await shell`say ${text}`.quiet(); return true; - } catch (e) { - debugLog(`speakWithSay error: ${e?.message || String(e) || 'Unknown error'}`); + } catch (error) { + debugLog(`speakWithSay error: ${getErrorMessage(error) || String(error) || 'Unknown error'}`); return false; } }; @@ -470,7 +536,7 @@ ${ssml} * OpenAI-Compatible TTS Engine (Kokoro, OpenAI, LocalAI, etc.) * Calls /v1/audio/speech endpoint with configurable base URL */ - const speakWithOpenAI = async (text) => { + const speakWithOpenAI = async (text: string): Promise => { if (!config.openaiTtsEndpoint) { debugLog('speakWithOpenAI: No endpoint configured'); return false; @@ -479,14 +545,14 @@ ${ssml} try { const endpoint = config.openaiTtsEndpoint.replace(/\/$/, ''); const url = `${endpoint}/v1/audio/speech`; - - const headers = { + + const headers: Record = { 'Content-Type': 'application/json', }; - + // Add auth header if API key is provided if (config.openaiTtsApiKey) { - headers['Authorization'] = `Bearer ${config.openaiTtsApiKey}`; + headers.Authorization = `Bearer ${config.openaiTtsApiKey}`; } const body = { @@ -516,10 +582,12 @@ ${ssml} fs.writeFileSync(tempFile, Buffer.from(audioBuffer)); await playAudioFile(tempFile); - try { fs.unlinkSync(tempFile); } catch (e) {} + try { + fs.unlinkSync(tempFile); + } catch {} return true; - } catch (e) { - debugLog(`speakWithOpenAI error: ${e?.message || String(e) || 'Unknown error'}`); + } catch (error) { + debugLog(`speakWithOpenAI error: ${getErrorMessage(error) || String(error) || 'Unknown error'}`); return false; } }; @@ -527,13 +595,13 @@ ${ssml} /** * Get the current system idle time in seconds. */ - const getSystemIdleSeconds = async () => { + const getSystemIdleSeconds = async (): Promise => { if (platform === 'linux') { // On Linux, we can't reliably detect idle time across all DEs // Return a high value to always attempt wake (it's a no-op if already awake) return 999; } - if (platform !== 'win32' || !$) return 999; + if (platform !== 'win32' || !shell) return 999; try { const cmd = ` Add-Type -TypeDefinition @' @@ -559,9 +627,9 @@ public static class IdleCheck { '@ [IdleCheck]::GetIdleSeconds() `; - const result = await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet(); - return parseInt(result.stdout?.toString().trim() || '0', 10); - } catch (e) { + const result = await shell`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet(); + return parseInt(outputToString(result.stdout).trim() || '0', 10); + } catch { return 999; // Assume idle on error } }; @@ -569,12 +637,12 @@ public static class IdleCheck { /** * Get the current system volume level (0-100). */ - const getCurrentVolume = async () => { + const getCurrentVolume = async (): Promise => { // Use Linux platform module if (platform === 'linux' && linux) { return await linux.getCurrentVolume(); } - if (platform !== 'win32' || !$) return -1; + if (platform !== 'win32' || !shell) return -1; try { const cmd = ` $signature = @' @@ -589,9 +657,9 @@ public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume); [Math]::Round(($leftVol / 65535) * 100) } else { -1 } `; - const result = await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet(); - return parseInt(result.stdout?.toString().trim() || '-1', 10); - } catch (e) { + const result = await shell`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet(); + return parseInt(outputToString(result.stdout).trim() || '-1', 10); + } catch { return -1; } }; @@ -599,8 +667,8 @@ public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume); /** * Wake Monitor Utility */ - const wakeMonitor = async (force = false) => { - if (!config.wakeMonitor || !$) return; + const wakeMonitor = async (force = false): Promise => { + if (!config.wakeMonitor || !shell) return; try { const idleSeconds = await getSystemIdleSeconds(); const threshold = config.idleThresholdSeconds || 30; @@ -611,29 +679,29 @@ public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume); } debugLog(`wakeMonitor: attempting to wake monitor (idle: ${idleSeconds}s, force: ${force})`); - + if (platform === 'win32') { const cmd = `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('{F15}')`; - await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet(); + await shell`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet(); debugLog('wakeMonitor: Windows wake command executed'); } else if (platform === 'darwin') { - await $`caffeinate -u -t 1`.quiet(); + await shell`caffeinate -u -t 1`.quiet(); debugLog('wakeMonitor: macOS wake command executed'); } else if (platform === 'linux' && linux) { // Use the Linux platform module for wake monitor await linux.wakeMonitor(); debugLog('wakeMonitor: Linux wake command executed'); } - } catch (e) { - debugLog(`wakeMonitor error: ${e.message}`); + } catch (error) { + debugLog(`wakeMonitor error: ${getErrorMessage(error)}`); } }; /** * Force Volume Utility */ - const forceVolume = async (force = false) => { - if (!config.forceVolume || !$) return; + const forceVolume = async (force = false): Promise => { + if (!config.forceVolume || !shell) return; try { if (!force) { const currentVolume = await getCurrentVolume(); @@ -643,61 +711,61 @@ public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume); if (platform === 'win32') { const cmd = `$wsh = New-Object -ComObject WScript.Shell; 1..50 | ForEach-Object { $wsh.SendKeys([char]175) }`; - await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet(); + await shell`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet(); } else if (platform === 'darwin') { - await $`osascript -e "set volume output volume 100"`.quiet(); + await shell`osascript -e "set volume output volume 100"`.quiet(); } else if (platform === 'linux' && linux) { // Use the Linux platform module for force volume await linux.forceVolume(); } - } catch (e) { - debugLog(`forceVolume error: ${e.message}`); + } catch (error) { + debugLog(`forceVolume error: ${getErrorMessage(error)}`); } }; /** * Main Speak function with fallback chain * Cascade: Primary Engine -> Edge TTS -> Windows SAPI -> macOS Say -> Sound File - * + * * Fallback ensures TTS works even if: * - Python edge-tts not installed (falls to npm package, then SAPI/Say) * - msedge-tts npm fails (403 errors - falls to SAPI/Say) * - User is on macOS without edge-tts (falls to built-in 'say' command) * - User is on Linux without edge-tts (falls to sound file only) */ - const speak = async (message, options = {}) => { - const activeConfig = { ...config, ...options }; + const speak = async (message: string, options: SpeakOptions = {}): Promise => { + const activeConfig = { ...config, ...options } as PluginConfig & SpeakOptions; if (!activeConfig.enableSound) return false; - + if (activeConfig.enableTTS) { let success = false; const engine = activeConfig.ttsEngine || 'elevenlabs'; - + if (engine === 'openai') { success = await speakWithOpenAI(message); if (!success) success = await speakWithEdgeTTS(message); if (!success) success = await speakWithSAPI(message); - if (!success) success = await speakWithSay(message); // macOS fallback + if (!success) success = await speakWithSay(message); // macOS fallback } else if (engine === 'elevenlabs') { success = await speakWithElevenLabs(message); if (!success) success = await speakWithEdgeTTS(message); if (!success) success = await speakWithSAPI(message); - if (!success) success = await speakWithSay(message); // macOS fallback + if (!success) success = await speakWithSay(message); // macOS fallback } else if (engine === 'edge') { success = await speakWithEdgeTTS(message); if (!success) success = await speakWithSAPI(message); - if (!success) success = await speakWithSay(message); // macOS fallback + if (!success) success = await speakWithSay(message); // macOS fallback } else if (engine === 'sapi') { success = await speakWithSAPI(message); if (!success) success = await speakWithSay(message); } - + if (success) return true; } if (activeConfig.fallbackSound) { - const soundPath = path.isAbsolute(activeConfig.fallbackSound) - ? activeConfig.fallbackSound + const soundPath = path.isAbsolute(activeConfig.fallbackSound) + ? activeConfig.fallbackSound : path.join(getConfigDir(), activeConfig.fallbackSound); await playAudioFile(soundPath, activeConfig.loops || 1); @@ -707,7 +775,7 @@ public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume); return { speak, - announce: async (message, options = {}) => { + announce: async (message: string, options: SpeakOptions = {}): Promise => { await wakeMonitor(); await forceVolume(); return speak(message, options); @@ -715,6 +783,6 @@ public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume); wakeMonitor, forceVolume, playAudioFile, - config + config, }; }; diff --git a/util/webhook.js b/src/util/webhook.ts similarity index 55% rename from util/webhook.js rename to src/util/webhook.ts index e642b0d..3d3c490 100644 --- a/util/webhook.js +++ b/src/util/webhook.ts @@ -1,25 +1,88 @@ +import fs from 'fs'; import os from 'os'; import path from 'path'; -import fs from 'fs'; + +import type { NotificationEventType } from '../types/config.js'; +import type { + DiscordEmbed, + DiscordEmbedField, + DiscordWebhookPayload, + NotificationResult, + RateLimitState, + WebhookNotifyOptions, + WebhookQueueItem, +} from '../types/notification.js'; /** * Webhook Module for OpenCode Smart Voice Notify - * + * * Provides Discord webhook integration for remote notifications. * Sends formatted notifications to Discord channels when the agent * needs attention (idle, permission, error, question events). - * + * * Features: * - Discord webhook format with rich embeds * - Rate limiting with automatic retry * - In-memory queue for reliability * - Fire-and-forget operation (non-blocking) * - Debug logging - * + * * @module util/webhook * @see docs/ARCHITECT_PLAN.md - Phase 4, Task 4.1 */ +type EventTypeKey = NotificationEventType | 'default' | (string & {}); + +interface WebhookValidationResult { + valid: boolean; + reason?: string; +} + +interface EmbedExtra { + fields?: DiscordEmbedField[]; + [key: string]: unknown; +} + +interface DiscordEmbedOptions { + eventType?: EventTypeKey; + title?: string; + message?: string; + projectName?: string; + sessionId?: string; + count?: number; + extra?: EmbedExtra; +} + +interface WebhookPayloadOptions { + username?: string; + avatarUrl?: string; + content?: string; + embeds?: DiscordEmbed[]; +} + +interface WebhookSendOptions { + retryCount?: number; + debugLog?: boolean; + timeout?: number; +} + +interface WebhookNotificationInput { + eventType: NotificationEventType | string; + title: string; + message: string; + projectName?: string; + sessionId?: string; + count?: number; + extra?: EmbedExtra; +} + +type QueueInput = Omit; + +const getErrorMessage = (error: unknown): string => { + const maybeError = error as { message?: unknown }; + return String(maybeError?.message); +}; + // ======================================== // QUEUE CONFIGURATION // ======================================== @@ -29,7 +92,7 @@ import fs from 'fs'; * Provides basic reliability - if a send fails, it can be retried. * Note: This is not persistent; queue is lost on process restart. */ -const webhookQueue = []; +const webhookQueue: WebhookQueueItem[] = []; /** * Maximum queue size to prevent memory issues. @@ -49,10 +112,10 @@ let isProcessingQueue = false; * Rate limit state tracking. * Discord rate limits webhooks, so we need to handle 429 responses. */ -let rateLimitState = { +let rateLimitState: RateLimitState = { isRateLimited: false, retryAfter: 0, - retryTimestamp: 0 + retryTimestamp: 0, }; /** @@ -73,25 +136,25 @@ const MAX_RETRIES = 3; * Debug logging to file. * Only logs when enabled. * Writes to ~/.config/opencode/logs/smart-voice-notify-debug.log - * - * @param {string} message - Message to log - * @param {boolean} enabled - Whether debug logging is enabled + * + * @param message - Message to log + * @param enabled - Whether debug logging is enabled */ -const debugLog = (message, enabled = false) => { +const debugLog = (message: string, enabled = false): void => { if (!enabled) return; - + try { const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode'); const logsDir = path.join(configDir, 'logs'); - + if (!fs.existsSync(logsDir)) { fs.mkdirSync(logsDir, { recursive: true }); } - + const logFile = path.join(logsDir, 'smart-voice-notify-debug.log'); const timestamp = new Date().toISOString(); fs.appendFileSync(logFile, `[${timestamp}] [webhook] ${message}\n`); - } catch (e) { + } catch { // Silently fail - logging should never break the plugin } }; @@ -104,23 +167,23 @@ const debugLog = (message, enabled = false) => { * Discord embed colors for different event types. * Colors are specified as decimal integers. */ -export const EMBED_COLORS = { - idle: 0x00ff00, // Green - task complete +export const EMBED_COLORS: Record = { + idle: 0x00ff00, // Green - task complete permission: 0xffaa00, // Orange/Amber - needs attention - error: 0xff0000, // Red - error - question: 0x0099ff, // Blue - question - default: 0x7289da // Discord blurple + error: 0xff0000, // Red - error + question: 0x0099ff, // Blue - question + default: 0x7289da, // Discord blurple }; /** * Emoji prefixes for different event types. */ -const EVENT_EMOJIS = { +const EVENT_EMOJIS: Record = { idle: '✅', permission: '⚠️', error: '❌', question: '❓', - default: '🔔' + default: '🔔', }; // ======================================== @@ -130,19 +193,19 @@ const EVENT_EMOJIS = { /** * Validate a webhook URL. * Currently supports Discord webhook URLs. - * - * @param {string} url - URL to validate - * @returns {{ valid: boolean, reason?: string }} Validation result + * + * @param url - URL to validate + * @returns Validation result */ -export const validateWebhookUrl = (url) => { +export const validateWebhookUrl = (url: string): WebhookValidationResult => { if (!url || typeof url !== 'string') { return { valid: false, reason: 'URL is required' }; } - + // Basic URL validation try { const parsed = new URL(url); - + // Check for Discord webhook pattern if (parsed.hostname === 'discord.com' || parsed.hostname === 'discordapp.com') { if (parsed.pathname.includes('/api/webhooks/')) { @@ -150,32 +213,25 @@ export const validateWebhookUrl = (url) => { } return { valid: false, reason: 'Invalid Discord webhook URL format' }; } - + // Allow generic webhooks for future expansion if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { return { valid: true }; } - + return { valid: false, reason: 'Invalid URL protocol' }; - } catch (e) { + } catch { return { valid: false, reason: 'Invalid URL format' }; } }; /** * Build a Discord embed object for a notification. - * - * @param {object} options - Embed options - * @param {string} options.eventType - Event type (idle, permission, error, question) - * @param {string} options.title - Embed title - * @param {string} options.message - Embed description/message - * @param {string} [options.projectName] - Project name for context - * @param {string} [options.sessionId] - Session ID for reference - * @param {number} [options.count] - Count for batched notifications - * @param {object} [options.extra] - Additional fields to add - * @returns {object} Discord embed object + * + * @param options - Embed options + * @returns Discord embed object */ -export const buildDiscordEmbed = (options) => { +export const buildDiscordEmbed = (options: DiscordEmbedOptions): DiscordEmbed => { const { eventType = 'default', title, @@ -183,131 +239,122 @@ export const buildDiscordEmbed = (options) => { projectName, sessionId, count, - extra = {} + extra = {}, } = options; - - const emoji = EVENT_EMOJIS[eventType] || EVENT_EMOJIS.default; - const color = EMBED_COLORS[eventType] || EMBED_COLORS.default; - - const embed = { + + const emoji = EVENT_EMOJIS[eventType as keyof typeof EVENT_EMOJIS] || EVENT_EMOJIS.default; + const color = EMBED_COLORS[eventType as keyof typeof EMBED_COLORS] || EMBED_COLORS.default; + + const embed: DiscordEmbed = { title: `${emoji} ${title || 'OpenCode Notification'}`, description: message || '', - color: color, + color, timestamp: new Date().toISOString(), footer: { - text: 'OpenCode Smart Voice Notify' - } + text: 'OpenCode Smart Voice Notify', + }, }; - + // Add fields for additional context - const fields = []; - + const fields: DiscordEmbedField[] = []; + if (projectName) { fields.push({ name: 'Project', value: projectName, - inline: true + inline: true, }); } - + if (eventType) { fields.push({ name: 'Event', value: eventType.charAt(0).toUpperCase() + eventType.slice(1), - inline: true + inline: true, }); } - + if (count && count > 1) { fields.push({ name: 'Count', value: String(count), - inline: true + inline: true, }); } - + if (sessionId) { fields.push({ name: 'Session', value: sessionId.substring(0, 8) + '...', - inline: true + inline: true, }); } - + // Add any extra fields if (extra.fields && Array.isArray(extra.fields)) { fields.push(...extra.fields); } - + if (fields.length > 0) { embed.fields = fields; } - + return embed; }; /** * Build a Discord webhook payload. - * - * @param {object} options - Payload options - * @param {string} [options.username='OpenCode Notify'] - Webhook username - * @param {string} [options.avatarUrl] - Avatar URL for the webhook - * @param {string} [options.content] - Plain text content (for mentions) - * @param {object[]} [options.embeds] - Array of embed objects - * @returns {object} Discord webhook payload + * + * @param options - Payload options + * @returns Discord webhook payload */ -export const buildWebhookPayload = (options) => { - const { - username = 'OpenCode Notify', - avatarUrl, - content, - embeds = [] - } = options; - - const payload = { - username: username +export const buildWebhookPayload = (options: WebhookPayloadOptions): DiscordWebhookPayload => { + const { username = 'OpenCode Notify', avatarUrl, content, embeds = [] } = options; + + const payload: DiscordWebhookPayload = { + username, }; - + if (avatarUrl) { payload.avatar_url = avatarUrl; } - + if (content) { payload.content = content; } - + if (embeds.length > 0) { payload.embeds = embeds; } - + return payload; }; /** * Check if we're currently rate limited. - * - * @returns {boolean} True if rate limited + * + * @returns True if rate limited */ -export const isRateLimited = () => { +export const isRateLimited = (): boolean => { if (!rateLimitState.isRateLimited) { return false; } - + // Check if rate limit has expired if (Date.now() >= rateLimitState.retryTimestamp) { rateLimitState.isRateLimited = false; return false; } - + return true; }; /** * Get the time until rate limit expires. - * - * @returns {number} Milliseconds until rate limit expires (0 if not limited) + * + * @returns Milliseconds until rate limit expires (0 if not limited) */ -export const getRateLimitWait = () => { +export const getRateLimitWait = (): number => { if (!isRateLimited()) { return 0; } @@ -316,37 +363,34 @@ export const getRateLimitWait = () => { /** * Wait for rate limit to expire. - * - * @param {boolean} [debug=false] - Enable debug logging - * @returns {Promise} + * + * @param debug - Enable debug logging */ -const waitForRateLimit = async (debug = false) => { +const waitForRateLimit = async (debug = false): Promise => { const waitTime = getRateLimitWait(); if (waitTime > 0) { debugLog(`Rate limited, waiting ${waitTime}ms`, debug); - await new Promise(resolve => setTimeout(resolve, waitTime)); + await new Promise((resolve) => { + setTimeout(resolve, waitTime); + }); } }; /** * Send a webhook message to Discord. * Handles rate limiting and retries automatically. - * - * @param {string} url - Webhook URL - * @param {object} payload - Webhook payload (Discord format) - * @param {object} [options={}] - Send options - * @param {number} [options.retryCount=0] - Current retry attempt - * @param {boolean} [options.debugLog=false] - Enable debug logging - * @param {number} [options.timeout=10000] - Request timeout in ms - * @returns {Promise<{ success: boolean, error?: string, statusCode?: number }>} + * + * @param url - Webhook URL + * @param payload - Webhook payload (Discord format) + * @param options - Send options */ -export const sendWebhookRequest = async (url, payload, options = {}) => { - const { - retryCount = 0, - debugLog: debug = false, - timeout = 10000 - } = options; - +export const sendWebhookRequest = async ( + url: string, + payload: DiscordWebhookPayload, + options: WebhookSendOptions = {}, +): Promise => { + const { retryCount = 0, debugLog: debug = false, timeout = 10000 } = options; + try { // Validate URL const validation = validateWebhookUrl(url); @@ -354,81 +398,81 @@ export const sendWebhookRequest = async (url, payload, options = {}) => { debugLog(`Invalid webhook URL: ${validation.reason}`, debug); return { success: false, error: validation.reason }; } - + // Wait for rate limit if necessary await waitForRateLimit(debug); - + debugLog(`Sending webhook request (attempt ${retryCount + 1}/${MAX_RETRIES + 1})`, debug); - + // Create abort controller for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); - + try { const response = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, body: JSON.stringify(payload), - signal: controller.signal + signal: controller.signal, }); - + clearTimeout(timeoutId); - + // Handle rate limiting (429) if (response.status === 429) { const retryAfter = response.headers.get('Retry-After'); - const retryMs = retryAfter - ? parseInt(retryAfter, 10) * 1000 - : DEFAULT_RETRY_DELAY_MS; - + const retryMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : DEFAULT_RETRY_DELAY_MS; + rateLimitState.isRateLimited = true; rateLimitState.retryAfter = retryMs; rateLimitState.retryTimestamp = Date.now() + retryMs; - + debugLog(`Rate limited (429), retry after ${retryMs}ms`, debug); - + // Retry if we haven't exceeded max retries if (retryCount < MAX_RETRIES) { await waitForRateLimit(debug); return sendWebhookRequest(url, payload, { ...options, - retryCount: retryCount + 1 + retryCount: retryCount + 1, }); } - - return { - success: false, + + return { + success: false, error: 'Rate limited, max retries exceeded', - statusCode: 429 + statusCode: 429, }; } - + // Success cases if (response.status === 204 || response.status === 200) { debugLog('Webhook sent successfully', debug); return { success: true, statusCode: response.status }; } - + // Other error cases const errorBody = await response.text().catch(() => 'Unknown error'); debugLog(`Webhook failed: ${response.status} - ${errorBody}`, debug); - + // Retry on 5xx errors if (response.status >= 500 && retryCount < MAX_RETRIES) { debugLog(`Server error (${response.status}), retrying...`, debug); - await new Promise(resolve => setTimeout(resolve, DEFAULT_RETRY_DELAY_MS)); + await new Promise((resolve) => { + setTimeout(resolve, DEFAULT_RETRY_DELAY_MS); + }); return sendWebhookRequest(url, payload, { ...options, - retryCount: retryCount + 1 + retryCount: retryCount + 1, }); } - - return { - success: false, + + return { + success: false, error: `HTTP ${response.status}: ${errorBody}`, - statusCode: response.status + statusCode: response.status, }; } catch (fetchError) { clearTimeout(timeoutId); @@ -436,22 +480,24 @@ export const sendWebhookRequest = async (url, payload, options = {}) => { } } catch (error) { // Handle timeout/abort - if (error.name === 'AbortError') { + const maybeAbortError = error as { name?: unknown }; + if (maybeAbortError?.name === 'AbortError') { debugLog(`Webhook request timed out after ${timeout}ms`, debug); - + // Retry on timeout if (retryCount < MAX_RETRIES) { return sendWebhookRequest(url, payload, { ...options, - retryCount: retryCount + 1 + retryCount: retryCount + 1, }); } - + return { success: false, error: 'Request timed out' }; } - - debugLog(`Webhook exception: ${error.message}`, debug); - return { success: false, error: error.message }; + + const errorMessage = getErrorMessage(error); + debugLog(`Webhook exception: ${errorMessage}`, debug); + return { success: false, error: errorMessage }; } }; @@ -461,74 +507,71 @@ export const sendWebhookRequest = async (url, payload, options = {}) => { /** * Add a message to the webhook queue. - * - * @param {object} item - Queue item - * @param {string} item.url - Webhook URL - * @param {object} item.payload - Webhook payload - * @param {object} [item.options] - Send options - * @returns {boolean} True if added, false if queue is full + * + * @param item - Queue item + * @returns True if added, false if queue is full */ -export const enqueueWebhook = (item) => { +export const enqueueWebhook = (item: QueueInput): boolean => { if (webhookQueue.length >= MAX_QUEUE_SIZE) { // Remove oldest item to make room webhookQueue.shift(); } - + webhookQueue.push({ ...item, - queuedAt: Date.now() + queuedAt: Date.now(), }); - + // Start processing if not already running if (!isProcessingQueue) { - processQueue(); + void processQueue(); } - + return true; }; /** * Process the webhook queue. * Sends queued messages one at a time, respecting rate limits. - * - * @returns {Promise} */ -const processQueue = async () => { +const processQueue = async (): Promise => { if (isProcessingQueue || webhookQueue.length === 0) { return; } - + isProcessingQueue = true; - + while (webhookQueue.length > 0) { const item = webhookQueue.shift(); - + if (!item) continue; - + await sendWebhookRequest(item.url, item.payload, item.options); - + // Small delay between messages to avoid hitting rate limits if (webhookQueue.length > 0) { - await new Promise(resolve => setTimeout(resolve, 250)); + await new Promise((resolve) => { + setTimeout(resolve, 250); + }); } } - + isProcessingQueue = false; }; /** * Get the current queue size. - * - * @returns {number} Number of items in queue + * + * @returns Number of items in queue */ -export const getQueueSize = () => webhookQueue.length; +export const getQueueSize = (): number => webhookQueue.length; /** * Clear the webhook queue. - * - * @returns {number} Number of items cleared + * + * @returns Number of items cleared */ -export const clearQueue = () => { +export const clearQueue = (): number => { const count = webhookQueue.length; webhookQueue.length = 0; return count; @@ -542,152 +585,161 @@ export const clearQueue = () => { * Send a webhook notification. * This is the main function for sending notifications via webhook. * Uses the queue for reliability and handles formatting automatically. - * - * @param {string} url - Webhook URL - * @param {object} notification - Notification details - * @param {string} notification.eventType - Event type (idle, permission, error, question) - * @param {string} notification.title - Notification title - * @param {string} notification.message - Notification message - * @param {string} [notification.projectName] - Project name - * @param {string} [notification.sessionId] - Session ID - * @param {number} [notification.count] - Count for batched notifications - * @param {object} [options={}] - Additional options - * @param {string} [options.username] - Webhook username - * @param {boolean} [options.mention=false] - Whether to mention @everyone - * @param {boolean} [options.useQueue=true] - Whether to use the queue - * @param {boolean} [options.debugLog=false] - Enable debug logging - * @returns {Promise<{ success: boolean, error?: string, queued?: boolean }>} + * + * @param url - Webhook URL + * @param notification - Notification details + * @param options - Additional options */ -export const sendWebhookNotification = async (url, notification, options = {}) => { - const { - username = 'OpenCode Notify', - mention = false, - useQueue = true, - debugLog: debug = false - } = options; - +export const sendWebhookNotification = async ( + url: string, + notification: WebhookNotificationInput, + options: WebhookNotifyOptions = {}, +): Promise => { + const { username = 'OpenCode Notify', mention = false, useQueue = true, debugLog: debug = false } = options; + try { // Build embed const embed = buildDiscordEmbed(notification); - + // Build payload const payload = buildWebhookPayload({ - username: username, + username, content: mention ? '@everyone' : undefined, - embeds: [embed] + embeds: [embed], }); - + debugLog(`Preparing webhook: ${notification.eventType} - ${notification.title}`, debug); - + // Use queue or send directly if (useQueue) { enqueueWebhook({ - url: url, - payload: payload, - options: { debugLog: debug } + url, + payload, + options: { debugLog: debug }, }); - + debugLog('Webhook queued for delivery', debug); return { success: true, queued: true }; - } else { - return await sendWebhookRequest(url, payload, { debugLog: debug }); } + + return await sendWebhookRequest(url, payload, { debugLog: debug }); } catch (error) { - debugLog(`Webhook notification error: ${error.message}`, debug); - return { success: false, error: error.message }; + const errorMessage = getErrorMessage(error); + debugLog(`Webhook notification error: ${errorMessage}`, debug); + return { success: false, error: errorMessage }; } }; /** * Send an idle notification webhook. * Pre-configured for task completion notifications. - * - * @param {string} url - Webhook URL - * @param {string} message - Notification message - * @param {object} [options={}] - Additional options - * @returns {Promise<{ success: boolean, error?: string, queued?: boolean }>} + * + * @param url - Webhook URL + * @param message - Notification message + * @param options - Additional options */ -export const notifyWebhookIdle = async (url, message, options = {}) => { - return sendWebhookNotification(url, { - eventType: 'idle', - title: options.projectName - ? `${options.projectName} - Task Complete` - : 'Task Complete', - message: message, - projectName: options.projectName, - sessionId: options.sessionId - }, options); +export const notifyWebhookIdle = async ( + url: string, + message: string, + options: WebhookNotifyOptions = {}, +): Promise => { + return sendWebhookNotification( + url, + { + eventType: 'idle', + title: options.projectName ? `${options.projectName} - Task Complete` : 'Task Complete', + message, + projectName: options.projectName, + sessionId: options.sessionId, + }, + options, + ); }; /** * Send a permission notification webhook. * Pre-configured for permission request notifications. - * - * @param {string} url - Webhook URL - * @param {string} message - Notification message - * @param {object} [options={}] - Additional options - * @returns {Promise<{ success: boolean, error?: string, queued?: boolean }>} + * + * @param url - Webhook URL + * @param message - Notification message + * @param options - Additional options */ -export const notifyWebhookPermission = async (url, message, options = {}) => { - return sendWebhookNotification(url, { - eventType: 'permission', - title: options.count > 1 - ? `${options.count} Permissions Required` - : 'Permission Required', - message: message, - projectName: options.projectName, - sessionId: options.sessionId, - count: options.count - }, { - ...options, - mention: options.mention !== undefined ? options.mention : true // Default to mention for permissions - }); +export const notifyWebhookPermission = async ( + url: string, + message: string, + options: WebhookNotifyOptions = {}, +): Promise => { + return sendWebhookNotification( + url, + { + eventType: 'permission', + title: options.count && options.count > 1 ? `${options.count} Permissions Required` : 'Permission Required', + message, + projectName: options.projectName, + sessionId: options.sessionId, + count: options.count, + }, + { + ...options, + mention: options.mention !== undefined ? options.mention : true, // Default to mention for permissions + }, + ); }; /** * Send an error notification webhook. * Pre-configured for error notifications. - * - * @param {string} url - Webhook URL - * @param {string} message - Notification message - * @param {object} [options={}] - Additional options - * @returns {Promise<{ success: boolean, error?: string, queued?: boolean }>} + * + * @param url - Webhook URL + * @param message - Notification message + * @param options - Additional options */ -export const notifyWebhookError = async (url, message, options = {}) => { - return sendWebhookNotification(url, { - eventType: 'error', - title: options.projectName - ? `${options.projectName} - Error` - : 'Agent Error', - message: message, - projectName: options.projectName, - sessionId: options.sessionId - }, { - ...options, - mention: options.mention !== undefined ? options.mention : true // Default to mention for errors - }); +export const notifyWebhookError = async ( + url: string, + message: string, + options: WebhookNotifyOptions = {}, +): Promise => { + return sendWebhookNotification( + url, + { + eventType: 'error', + title: options.projectName ? `${options.projectName} - Error` : 'Agent Error', + message, + projectName: options.projectName, + sessionId: options.sessionId, + }, + { + ...options, + mention: options.mention !== undefined ? options.mention : true, // Default to mention for errors + }, + ); }; /** * Send a question notification webhook. * Pre-configured for question notifications. - * - * @param {string} url - Webhook URL - * @param {string} message - Notification message - * @param {object} [options={}] - Additional options - * @returns {Promise<{ success: boolean, error?: string, queued?: boolean }>} + * + * @param url - Webhook URL + * @param message - Notification message + * @param options - Additional options */ -export const notifyWebhookQuestion = async (url, message, options = {}) => { - return sendWebhookNotification(url, { - eventType: 'question', - title: options.count > 1 - ? `${options.count} Questions Need Your Input` - : 'Question', - message: message, - projectName: options.projectName, - sessionId: options.sessionId, - count: options.count - }, options); +export const notifyWebhookQuestion = async ( + url: string, + message: string, + options: WebhookNotifyOptions = {}, +): Promise => { + return sendWebhookNotification( + url, + { + eventType: 'question', + title: options.count && options.count > 1 ? `${options.count} Questions Need Your Input` : 'Question', + message, + projectName: options.projectName, + sessionId: options.sessionId, + count: options.count, + }, + options, + ); }; // ======================================== @@ -698,7 +750,7 @@ export const notifyWebhookQuestion = async (url, message, options = {}) => { * Reset rate limit state. * Used for testing. */ -export const resetRateLimitState = () => { +export const resetRateLimitState = (): void => { rateLimitState.isRateLimited = false; rateLimitState.retryAfter = 0; rateLimitState.retryTimestamp = 0; @@ -707,10 +759,10 @@ export const resetRateLimitState = () => { /** * Get rate limit state. * Used for testing and debugging. - * - * @returns {object} Current rate limit state + * + * @returns Current rate limit state */ -export const getRateLimitState = () => ({ ...rateLimitState }); +export const getRateLimitState = (): RateLimitState => ({ ...rateLimitState }); // Default export for convenience export default { @@ -720,24 +772,24 @@ export default { validateWebhookUrl, buildDiscordEmbed, buildWebhookPayload, - + // Rate limiting isRateLimited, getRateLimitWait, resetRateLimitState, getRateLimitState, - + // Queue functions enqueueWebhook, getQueueSize, clearQueue, - + // High-level helpers notifyWebhookIdle, notifyWebhookPermission, notifyWebhookError, notifyWebhookQuestion, - + // Constants - EMBED_COLORS + EMBED_COLORS, }; diff --git a/tests/e2e/config-integration.test.js b/tests/e2e/config-integration.test.ts similarity index 98% rename from tests/e2e/config-integration.test.js rename to tests/e2e/config-integration.test.ts index 64821f1..acd2f2d 100644 --- a/tests/e2e/config-integration.test.js +++ b/tests/e2e/config-integration.test.ts @@ -1,7 +1,8 @@ +// @ts-nocheck import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import fs from 'fs'; import path from 'path'; -import SmartVoiceNotifyPlugin from '../../index.js'; +import SmartVoiceNotifyPlugin from '../../src/index.js'; import { createTestTempDir, cleanupTestTempDir, diff --git a/tests/e2e/context-aware-ai.test.js b/tests/e2e/context-aware-ai.test.ts similarity index 98% rename from tests/e2e/context-aware-ai.test.js rename to tests/e2e/context-aware-ai.test.ts index 8a00c8f..02491e4 100644 --- a/tests/e2e/context-aware-ai.test.js +++ b/tests/e2e/context-aware-ai.test.ts @@ -1,8 +1,9 @@ +// @ts-nocheck import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import fs from 'fs'; import path from 'path'; -import SmartVoiceNotifyPlugin from '../../index.js'; -import { generateAIMessage } from '../../util/ai-messages.js'; +import SmartVoiceNotifyPlugin from '../../src/index.js'; +import { generateAIMessage } from '../../src/util/ai-messages.js'; import { createTestTempDir, cleanupTestTempDir, diff --git a/tests/e2e/plugin.test.js b/tests/e2e/plugin.test.ts similarity index 99% rename from tests/e2e/plugin.test.js rename to tests/e2e/plugin.test.ts index e474baa..b0642ca 100644 --- a/tests/e2e/plugin.test.js +++ b/tests/e2e/plugin.test.ts @@ -1,7 +1,8 @@ +// @ts-nocheck import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; import fs from 'fs'; import path from 'path'; -import SmartVoiceNotifyPlugin from '../../index.js'; +import SmartVoiceNotifyPlugin from '../../src/index.js'; import { createTestTempDir, cleanupTestTempDir, diff --git a/tests/e2e/reminder-flow.test.js b/tests/e2e/reminder-flow.test.ts similarity index 99% rename from tests/e2e/reminder-flow.test.js rename to tests/e2e/reminder-flow.test.ts index a098909..66d3a0e 100644 --- a/tests/e2e/reminder-flow.test.js +++ b/tests/e2e/reminder-flow.test.ts @@ -1,5 +1,6 @@ +// @ts-nocheck import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import SmartVoiceNotifyPlugin from '../../index.js'; +import SmartVoiceNotifyPlugin from '../../src/index.js'; import { createTestTempDir, cleanupTestTempDir, diff --git a/tests/integration/ai-messages.test.js b/tests/integration/ai-messages.test.ts similarity index 95% rename from tests/integration/ai-messages.test.js rename to tests/integration/ai-messages.test.ts index d02eb92..e631cfa 100644 --- a/tests/integration/ai-messages.test.js +++ b/tests/integration/ai-messages.test.ts @@ -1,5 +1,6 @@ +// @ts-nocheck import { test, describe, expect, beforeAll, afterAll } from 'bun:test'; -import { generateAIMessage, testAIConnection } from '../../util/ai-messages.js'; +import { generateAIMessage, testAIConnection } from '../../src/util/ai-messages.js'; import { createTestTempDir, cleanupTestTempDir, createTestConfig } from '../setup.js'; const hasAIEndpoint = !!process.env.TEST_AI_ENDPOINT && process.env.TEST_AI_ENDPOINT !== 'http://127.0.0.1:8000/v1'; diff --git a/tests/integration/elevenlabs.test.js b/tests/integration/elevenlabs.test.ts similarity index 97% rename from tests/integration/elevenlabs.test.js rename to tests/integration/elevenlabs.test.ts index 4b920df..0d4a46c 100644 --- a/tests/integration/elevenlabs.test.js +++ b/tests/integration/elevenlabs.test.ts @@ -1,5 +1,6 @@ +// @ts-nocheck import { test, describe, expect, beforeAll, afterAll } from 'bun:test'; -import { createTTS } from '../../util/tts.js'; +import { createTTS } from '../../src/util/tts.js'; import { createMockShellRunner, createMockClient, createTestTempDir, cleanupTestTempDir, createTestConfig } from '../setup.js'; import fs from 'fs'; import path from 'path'; diff --git a/tests/integration/openai-tts.test.js b/tests/integration/openai-tts.test.ts similarity index 95% rename from tests/integration/openai-tts.test.js rename to tests/integration/openai-tts.test.ts index 52c2db3..8f3c87f 100644 --- a/tests/integration/openai-tts.test.js +++ b/tests/integration/openai-tts.test.ts @@ -1,5 +1,6 @@ +// @ts-nocheck import { test, describe, expect, beforeAll, afterAll } from 'bun:test'; -import { createTTS } from '../../util/tts.js'; +import { createTTS } from '../../src/util/tts.js'; import { createMockShellRunner, createMockClient, createTestTempDir, cleanupTestTempDir, createTestConfig } from '../setup.js'; const hasOpenAIEndpoint = !!process.env.TEST_OPENAI_TTS_ENDPOINT && process.env.TEST_OPENAI_TTS_ENDPOINT !== 'https://api.example.com'; diff --git a/tests/setup.js b/tests/setup.js deleted file mode 100644 index 84f42d0..0000000 --- a/tests/setup.js +++ /dev/null @@ -1,715 +0,0 @@ -/** - * Test Setup Preload File - * - * This file is loaded before all tests run (via bunfig.toml preload). - * It sets up the test environment with: - * - Temporary directory for file isolation - * - Environment variables for test mode - * - Global test helpers and utilities - * - * @see docs/ARCHITECT_PLAN.md - Phase 0, Task 0.3 - */ - -import { beforeAll, afterAll, beforeEach, afterEach } from 'bun:test'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -// ============================================================ -// TEST ENVIRONMENT CONFIGURATION -// ============================================================ - -/** - * Base temporary directory for all test runs. - * Each test file gets its own subdirectory to prevent conflicts. - */ -const TEST_TEMP_BASE = path.join(os.tmpdir(), 'opencode-smart-voice-notify-tests'); - -/** - * Current test's temporary directory (set per-test-file) - */ -let currentTestDir = null; - -// ============================================================ -// ENVIRONMENT VARIABLES FOR TEST MODE -// ============================================================ - -// Mark that we're in test mode -process.env.NODE_ENV = 'test'; - -// Disable debug logging during tests (can be overridden per-test) -process.env.SMART_VOICE_NOTIFY_DEBUG = 'false'; - -// ============================================================ -// TEMPORARY DIRECTORY MANAGEMENT -// ============================================================ - -/** - * Creates a unique temporary directory for the current test file. - * Sets OPENCODE_CONFIG_DIR to redirect all file operations. - * - * @returns {string} Path to the created temp directory - */ -export function createTestTempDir() { - // Generate unique directory name using timestamp and random suffix - const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; - const tempDir = path.join(TEST_TEMP_BASE, uniqueId); - - // Create the directory structure - fs.mkdirSync(tempDir, { recursive: true }); - - // Set environment variable to redirect config operations - process.env.OPENCODE_CONFIG_DIR = tempDir; - - // Store reference for cleanup - currentTestDir = tempDir; - - return tempDir; -} - -/** - * Cleans up the current test's temporary directory. - * Safe to call multiple times. - */ -export function cleanupTestTempDir() { - if (currentTestDir && fs.existsSync(currentTestDir)) { - try { - fs.rmSync(currentTestDir, { recursive: true, force: true }); - } catch (e) { - // Ignore cleanup errors (Windows file locking, etc.) - } - currentTestDir = null; - } - - // Reset environment variable - delete process.env.OPENCODE_CONFIG_DIR; -} - -/** - * Gets the current test's temporary directory path. - * Creates one if it doesn't exist. - * - * @returns {string} Path to the current temp directory - */ -export function getTestTempDir() { - if (!currentTestDir) { - return createTestTempDir(); - } - return currentTestDir; -} - -// ============================================================ -// TEST FIXTURE HELPERS -// ============================================================ - -/** - * Creates a test config file in the temp directory. - * - * @param {object} config - Configuration object to write - * @param {string} [filename='smart-voice-notify.jsonc'] - Config filename - * @returns {string} Path to the created config file - */ -export function createTestConfig(config, filename = 'smart-voice-notify.jsonc') { - const tempDir = getTestTempDir(); - const configPath = path.join(tempDir, filename); - - // Write as JSONC (with optional comments support via JSON.stringify) - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); - - return configPath; -} - -/** - * Creates a minimal test config with sensible defaults for testing. - * - * @param {object} [overrides={}] - Properties to override defaults - * @returns {object} Test configuration object - */ -export function createMinimalConfig(overrides = {}) { - return { - _configVersion: '1.0.0', - enabled: true, - notificationMode: 'sound-first', - enableTTS: false, // Disable TTS in tests by default - enableTTSReminder: false, // Disable reminders in tests by default - enableSound: false, // Disable sounds in tests by default - enableToast: false, // Disable toasts in tests by default - enableAIMessages: false, // Disable AI message generation in tests (avoids network calls) - debugLog: false, // Disable debug logging in tests - ...overrides - }; -} - -/** - * Creates the assets directory with a minimal test audio file. - * - * @returns {string} Path to the created assets directory - */ -export function createTestAssets() { - const tempDir = getTestTempDir(); - const assetsDir = path.join(tempDir, 'assets'); - - fs.mkdirSync(assetsDir, { recursive: true }); - - // Create a minimal valid MP3 file (ID3 header + frame) - // This is the smallest valid MP3 that most players won't choke on - const minimalMp3 = Buffer.from([ - 0xFF, 0xFB, 0x90, 0x00, // MPEG Audio Frame Header - 0x00, 0x00, 0x00, 0x00, // Padding - ]); - - // Create test sound files - const soundFiles = [ - 'Soft-high-tech-notification-sound-effect.mp3', - 'Machine-alert-beep-sound-effect.mp3', - 'test-sound.mp3' - ]; - - for (const file of soundFiles) { - fs.writeFileSync(path.join(assetsDir, file), minimalMp3); - } - - return assetsDir; -} - -/** - * Creates a mock logs directory. - * - * @returns {string} Path to the created logs directory - */ -export function createTestLogsDir() { - const tempDir = getTestTempDir(); - const logsDir = path.join(tempDir, 'logs'); - - fs.mkdirSync(logsDir, { recursive: true }); - - return logsDir; -} - -/** - * Reads a file from the test temp directory. - * - * @param {string} relativePath - Path relative to temp directory - * @returns {string|null} File contents or null if not found - */ -export function readTestFile(relativePath) { - const tempDir = getTestTempDir(); - const filePath = path.join(tempDir, relativePath); - - try { - return fs.readFileSync(filePath, 'utf-8'); - } catch (e) { - return null; - } -} - -/** - * Checks if a file exists in the test temp directory. - * - * @param {string} relativePath - Path relative to temp directory - * @returns {boolean} True if file exists - */ -export function testFileExists(relativePath) { - const tempDir = getTestTempDir(); - const filePath = path.join(tempDir, relativePath); - - return fs.existsSync(filePath); -} - -// ============================================================ -// MOCK FACTORY UTILITIES -// ============================================================ - -/** - * Creates a mock shell runner ($) for testing. - * Records all commands executed for verification. - * - * @param {object} [options={}] - Mock options - * @param {function} [options.handler] - Custom handler for commands - * @returns {object} Mock shell runner with call history - */ -export function createMockShellRunner(options = {}) { - const calls = []; - - const mockRunner = (strings, ...values) => { - // Reconstruct the command from template literal - let command = strings[0]; - for (let i = 0; i < values.length; i++) { - command += String(values[i]) + strings[i + 1]; - } - - const callRecord = { - command: command.trim(), - timestamp: Date.now() - }; - calls.push(callRecord); - - // Create a promise that resolves to the result - const promise = (async () => { - // Allow custom handler for specific commands - if (options.handler) { - const handlerResult = await options.handler(callRecord.command, callRecord); - // If handler returns a simple object, merge it with default result - if (handlerResult && typeof handlerResult === 'object' && !(handlerResult instanceof Buffer)) { - return { - stdout: Buffer.from(''), - stderr: Buffer.from(''), - exitCode: 0, - text: () => '', - toString: () => '', - ...handlerResult - }; - } - return handlerResult; - } - - // Default: return empty successful result - return { - stdout: Buffer.from(''), - stderr: Buffer.from(''), - exitCode: 0, - text: () => '', - toString: () => '' - }; - })(); - - // Add Bun shell methods to the promise - promise.quiet = function() { return this; }; - promise.nothrow = function() { return this; }; - promise.timeout = function() { return this; }; - - return promise; - }; - - // Add utility methods - mockRunner.getCalls = () => [...calls]; - mockRunner.getLastCall = () => calls[calls.length - 1]; - mockRunner.getCallCount = () => calls.length; - mockRunner.reset = () => { calls.length = 0; }; - mockRunner.wasCalledWith = (pattern) => calls.some(c => - typeof pattern === 'string' - ? c.command.includes(pattern) - : pattern.test(c.command) - ); - - return mockRunner; -} - -/** - * Creates a mock OpenCode SDK client for testing. - * - * @param {object} [options={}] - Mock options - * @returns {object} Mock client with common methods - */ -export function createMockClient(options = {}) { - const toastCalls = []; - const sessionData = new Map(); - - return { - tui: { - showToast: async ({ body }) => { - toastCalls.push({ - message: body.message, - variant: body.variant, - duration: body.duration, - timestamp: Date.now() - }); - return { success: true }; - }, - getToastCalls: () => [...toastCalls], - resetToastCalls: () => { toastCalls.length = 0; } - }, - - session: { - get: async ({ path: { id } }) => { - // Return mock session data - const session = sessionData.get(id) || { - id, - parentID: null, - status: 'idle' - }; - return { data: session }; - }, - setMockSession: (id, data) => { - sessionData.set(id, { id, ...data }); - }, - clearMockSessions: () => { - sessionData.clear(); - } - }, - - app: { - log: async ({ service, level, message, extra }) => { - // Silent in tests - return { success: true }; - } - }, - - permission: { - reply: async ({ body }) => { - return { success: true }; - } - }, - - question: { - reply: async ({ body }) => { - return { success: true }; - }, - reject: async ({ body }) => { - return { success: true }; - } - } - }; -} - -/** - * Creates a mock event for testing plugin event handlers. - * - * @param {string} type - Event type (e.g., 'session.idle', 'permission.updated') - * @param {object} [properties={}] - Event properties - * @returns {object} Mock event object - */ -export function createMockEvent(type, properties = {}) { - return { - type, - properties: { - sessionID: properties.sessionID || `test-session-${Date.now()}`, - ...properties - } - }; -} - -/** - * Creates common mock events for testing. - */ -export const mockEvents = { - sessionIdle: (sessionID) => createMockEvent('session.idle', { sessionID }), - - sessionError: (sessionID) => createMockEvent('session.error', { sessionID }), - - sessionCreated: (sessionID) => createMockEvent('session.created', { - sessionID, - info: { id: sessionID } // Include info object for debounce cleanup - }), - - permissionAsked: (id, sessionID) => createMockEvent('permission.asked', { - id: id || `perm-${Date.now()}`, - sessionID - }), - - permissionReplied: (requestID, reply = 'once') => createMockEvent('permission.replied', { - requestID, - reply - }), - - questionAsked: (id, sessionID, questions = [{ text: 'Test question?' }]) => - createMockEvent('question.asked', { - id: id || `q-${Date.now()}`, - sessionID, - questions - }), - - questionReplied: (requestID, answers = [['answer']]) => createMockEvent('question.replied', { - requestID, - answers - }), - - questionRejected: (requestID) => createMockEvent('question.rejected', { - requestID - }), - - messageUpdated: (messageId, role = 'user', sessionID) => createMockEvent('message.updated', { - sessionID, - info: { - id: messageId || `msg-${Date.now()}`, - role, - time: { created: Date.now() / 1000 } - } - }) -}; - -// ============================================================ -// ASYNC TEST UTILITIES -// ============================================================ - -/** - * Waits for a specified number of milliseconds. - * Useful for testing debounced/delayed operations. - * - * @param {number} ms - Milliseconds to wait - * @returns {Promise} - */ -export function wait(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -/** - * Waits for a condition to become true. - * - * @param {function} condition - Function returning boolean or promise - * @param {number} [timeout=5000] - Maximum time to wait - * @param {number} [interval=50] - Check interval - * @returns {Promise} - */ -export async function waitFor(condition, timeout = 5000, interval = 50) { - const start = Date.now(); - - while (Date.now() - start < timeout) { - const result = await condition(); - if (result) return; - await wait(interval); - } - - throw new Error(`Condition not met within ${timeout}ms`); -} - -// ============================================================ -// GLOBAL SETUP/TEARDOWN HOOKS -// ============================================================ - -// Ensure the base temp directory exists at startup -beforeAll(() => { - if (!fs.existsSync(TEST_TEMP_BASE)) { - fs.mkdirSync(TEST_TEMP_BASE, { recursive: true }); - } -}); - -// Clean up after all tests complete -afterAll(() => { - // Clean up the entire test temp base if empty - try { - const contents = fs.readdirSync(TEST_TEMP_BASE); - if (contents.length === 0) { - fs.rmdirSync(TEST_TEMP_BASE); - } - } catch (e) { - // Ignore errors - } -}); - -// Reset environment for each test -beforeEach(() => { - // Reset NODE_ENV to test - process.env.NODE_ENV = 'test'; -}); - -// Clean up temp directory after each test (if created) -afterEach(() => { - cleanupTestTempDir(); -}); - -// ============================================================ -// CONSOLE OUTPUT CAPTURE (Optional) -// ============================================================ - -/** - * Captures console output during test execution. - * Useful for testing debug logging. - * - * @returns {object} Capture controller with start/stop/get methods - */ -export function createConsoleCapture() { - const logs = { log: [], warn: [], error: [], info: [], debug: [] }; - const original = { - log: console.log, - warn: console.warn, - error: console.error, - info: console.info, - debug: console.debug - }; - let capturing = false; - - return { - start() { - if (capturing) return; - capturing = true; - - for (const type of Object.keys(original)) { - console[type] = (...args) => { - logs[type].push(args); - }; - } - }, - - stop() { - if (!capturing) return; - capturing = false; - - for (const [type, fn] of Object.entries(original)) { - console[type] = fn; - } - }, - - get(type) { - return type ? logs[type] : logs; - }, - - clear() { - for (const type of Object.keys(logs)) { - logs[type].length = 0; - } - } - }; -} - -// ============================================================ -// PLATFORM-AWARE TEST UTILITIES -// ============================================================ - -/** - * Get the current platform - * @returns {string} 'win32', 'darwin', or 'linux' - */ -export const platform = os.platform(); - -/** - * Check if running on Windows - * @returns {boolean} - */ -export const isWindows = platform === 'win32'; - -/** - * Check if running on macOS - * @returns {boolean} - */ -export const isMacOS = platform === 'darwin'; - -/** - * Check if running on Linux - * @returns {boolean} - */ -export const isLinux = platform === 'linux'; - -/** - * Helper to detect TTS calls in mock shell history. - * Works across all platforms by checking for platform-specific TTS commands. - * - * @param {object} shell - Mock shell runner from createMockShellRunner() - * @returns {Array} Array of TTS-related calls - */ -export function getTTSCalls(shell) { - return shell.getCalls().filter(c => { - const cmd = c.command; - // Windows SAPI TTS - if (cmd.includes('powershell.exe') && cmd.includes('-File') && cmd.includes('.ps1')) { - return true; - } - // Edge TTS / ElevenLabs / OpenAI TTS audio playback - // These engines generate audio files and play them via playAudioFile - if (cmd.includes('paplay') || cmd.includes('aplay') || cmd.includes('afplay')) { - return true; - } - // macOS say command - if (cmd.includes('say ')) { - return true; - } - // Windows MediaPlayer (used by playAudioFile) - if (cmd.includes('System.Windows.Media.MediaPlayer')) { - return true; - } - return false; - }); -} - -/** - * Helper to detect any audio playback calls (sound or TTS) in mock shell history. - * This includes both audio file playback AND TTS speech (which produces audio). - * - * @param {object} shell - Mock shell runner from createMockShellRunner() - * @returns {Array} Array of audio-related calls - */ -export function getAudioCalls(shell) { - return shell.getCalls().filter(c => { - const cmd = c.command; - // Windows audio playback (MediaPlayer) - if (cmd.includes('System.Windows.Media.MediaPlayer')) { - return true; - } - // Windows SAPI TTS (PowerShell script that speaks via System.Speech) - // This also produces audio output! - if (cmd.includes('powershell.exe') && cmd.includes('-File') && cmd.includes('.ps1')) { - return true; - } - // Edge TTS CLI command (produces audio file then plays it) - if (cmd.includes('edge-tts') && cmd.includes('--write-media')) { - return true; - } - // Linux audio playback - if (cmd.includes('paplay') || cmd.includes('aplay')) { - return true; - } - // macOS audio playback - if (cmd.includes('afplay')) { - return true; - } - // macOS say command (TTS) - if (cmd.includes('say ')) { - return true; - } - return false; - }); -} - -/** - * Get the recommended TTS engine for the current platform. - * Use 'edge' for cross-platform tests, 'sapi' only on Windows. - * - * @returns {string} 'edge' on Linux/macOS, 'sapi' on Windows - */ -export function getTestTTSEngine() { - return isWindows ? 'sapi' : 'edge'; -} - -/** - * Check if TTS was called on the current platform. - * Platform-aware version of wasCalledWith for TTS detection. - * - * @param {object} shell - Mock shell runner - * @returns {boolean} True if any TTS call was detected - */ -export function wasTTSCalled(shell) { - return getTTSCalls(shell).length > 0; -} - -// ============================================================ -// EXPORTS SUMMARY -// ============================================================ - -// All exports are named exports above. Default export for convenience: -export default { - // Temp directory management - createTestTempDir, - cleanupTestTempDir, - getTestTempDir, - - // Fixture helpers - createTestConfig, - createMinimalConfig, - createTestAssets, - createTestLogsDir, - readTestFile, - testFileExists, - - // Mock factories - createMockShellRunner, - createMockClient, - createMockEvent, - mockEvents, - - // Async utilities - wait, - waitFor, - - // Console capture - createConsoleCapture, - - // Platform utilities - platform, - isWindows, - isMacOS, - isLinux, - getTTSCalls, - getAudioCalls, - getTestTTSEngine, - wasTTSCalled -}; diff --git a/tests/setup.test.js b/tests/setup.test.ts similarity index 99% rename from tests/setup.test.js rename to tests/setup.test.ts index 79b2854..76d75dd 100644 --- a/tests/setup.test.js +++ b/tests/setup.test.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * Setup Infrastructure Smoke Test * diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..a4bcc7f --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,545 @@ +import { afterAll, afterEach, beforeAll, beforeEach } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { + ConsoleCapture, + ConsoleCaptureStore, + ConsoleMethod, + MockClient, + MockSession, + MockShellResult, + MockShellRunner, + ShellCallRecord, + ToastBody, + ToastCall, +} from '../src/types/testing.js'; +import type { PluginEvent, ShellResult } from '../src/types/opencode-sdk.js'; + +const TEST_TEMP_BASE = path.join(os.tmpdir(), 'opencode-smart-voice-notify-tests'); + +let currentTestDir: string | null = null; + +process.env.NODE_ENV = 'test'; +process.env.SMART_VOICE_NOTIFY_DEBUG = 'false'; + +type GenericObject = Record; + +interface MockShellRunnerOptions { + handler?: ( + command: string, + callRecord: ShellCallRecord, + ) => + | ShellResult + | Partial + | Buffer + | null + | undefined + | Promise | Buffer | null | undefined>; +} + +const getDefaultShellResult = (): ShellResult => ({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + text: () => '', + toString: () => '', +}); + +export function createTestTempDir(): string { + const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; + const tempDir = path.join(TEST_TEMP_BASE, uniqueId); + + fs.mkdirSync(tempDir, { recursive: true }); + process.env.OPENCODE_CONFIG_DIR = tempDir; + currentTestDir = tempDir; + + return tempDir; +} + +export function cleanupTestTempDir(): void { + if (currentTestDir && fs.existsSync(currentTestDir)) { + try { + fs.rmSync(currentTestDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors. + } + currentTestDir = null; + } + + delete process.env.OPENCODE_CONFIG_DIR; +} + +export function getTestTempDir(): string { + if (!currentTestDir) { + return createTestTempDir(); + } + return currentTestDir; +} + +export function createTestConfig(config: GenericObject, filename = 'smart-voice-notify.jsonc'): string { + const tempDir = getTestTempDir(); + const configPath = path.join(tempDir, filename); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + return configPath; +} + +export function createMinimalConfig( + overrides: TOverrides = {} as TOverrides, +): + { + _configVersion: string; + enabled: boolean; + notificationMode: 'sound-first'; + enableTTS: boolean; + enableTTSReminder: boolean; + enableSound: boolean; + enableToast: boolean; + enableAIMessages: boolean; + debugLog: boolean; + } & TOverrides { + return { + _configVersion: '1.0.0', + enabled: true, + notificationMode: 'sound-first', + enableTTS: false, + enableTTSReminder: false, + enableSound: false, + enableToast: false, + enableAIMessages: false, + debugLog: false, + ...overrides, + }; +} + +export function createTestAssets(): string { + const tempDir = getTestTempDir(); + const assetsDir = path.join(tempDir, 'assets'); + + fs.mkdirSync(assetsDir, { recursive: true }); + + const minimalMp3 = Buffer.from([ + 0xff, 0xfb, 0x90, 0x00, + 0x00, 0x00, 0x00, 0x00, + ]); + + const soundFiles = [ + 'Soft-high-tech-notification-sound-effect.mp3', + 'Machine-alert-beep-sound-effect.mp3', + 'test-sound.mp3', + ]; + + for (const file of soundFiles) { + fs.writeFileSync(path.join(assetsDir, file), minimalMp3); + } + + return assetsDir; +} + +export function createTestLogsDir(): string { + const tempDir = getTestTempDir(); + const logsDir = path.join(tempDir, 'logs'); + fs.mkdirSync(logsDir, { recursive: true }); + return logsDir; +} + +export function readTestFile(relativePath: string): string | null { + const tempDir = getTestTempDir(); + const filePath = path.join(tempDir, relativePath); + + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return null; + } +} + +export function testFileExists(relativePath: string): boolean { + const tempDir = getTestTempDir(); + const filePath = path.join(tempDir, relativePath); + return fs.existsSync(filePath); +} + +export function createMockShellRunner(options: MockShellRunnerOptions = {}): MockShellRunner { + const calls: ShellCallRecord[] = []; + + const mockRunner = ((strings: TemplateStringsArray, ...values: Array) => { + let command = strings[0] ?? ''; + for (let index = 0; index < values.length; index += 1) { + command += String(values[index]) + (strings[index + 1] ?? ''); + } + + const callRecord: ShellCallRecord = { + command: command.trim(), + timestamp: Date.now(), + }; + calls.push(callRecord); + + const promise = (async (): Promise => { + if (options.handler) { + const handlerResult = await options.handler(callRecord.command, callRecord); + if (handlerResult && typeof handlerResult === 'object' && !Buffer.isBuffer(handlerResult)) { + return { + ...getDefaultShellResult(), + ...(handlerResult as Partial), + }; + } + } + + return getDefaultShellResult(); + })(); + + const mockPromise = promise as MockShellResult; + mockPromise.quiet = () => mockPromise; + mockPromise.nothrow = () => mockPromise; + mockPromise.timeout = () => mockPromise; + + return mockPromise; + }) as MockShellRunner; + + mockRunner.getCalls = () => [...calls]; + mockRunner.getLastCall = () => calls[calls.length - 1]; + mockRunner.getCallCount = () => calls.length; + mockRunner.reset = () => { + calls.length = 0; + }; + mockRunner.wasCalledWith = (pattern: string | RegExp) => + calls.some((record) => + typeof pattern === 'string' ? record.command.includes(pattern) : pattern.test(record.command), + ); + + return mockRunner; +} + +export function createMockClient(): MockClient { + const toastCalls: ToastCall[] = []; + const sessionData = new Map(); + + const client: MockClient = { + tui: { + showToast: async ({ body }: { body: ToastBody & { title?: string } }) => { + toastCalls.push({ + message: body.message, + variant: body.variant, + duration: body.duration, + timestamp: Date.now(), + }); + return { success: true }; + }, + getToastCalls: () => [...toastCalls], + resetToastCalls: () => { + toastCalls.length = 0; + }, + }, + + session: { + get: async ({ path: { id } }: { path: { id: string } }) => { + const session = + sessionData.get(id) ?? + ({ + id, + parentID: null, + status: 'idle', + } as MockSession); + return { data: session }; + }, + setMockSession: (id: string, data: Partial) => { + sessionData.set(id, { id, ...data }); + }, + clearMockSessions: () => { + sessionData.clear(); + }, + }, + + app: { + log: async (_input: unknown) => ({ success: true }), + }, + + permission: { + reply: async (_input: unknown) => ({ success: true }), + }, + + question: { + reply: async (_input: unknown) => ({ success: true }), + reject: async (_input: unknown) => ({ success: true }), + }, + }; + + return client; +} + +export function createMockEvent(type: PluginEvent['type'], properties: GenericObject = {}): PluginEvent { + const sessionIdFromProperties = properties.sessionID; + const sessionID = + typeof sessionIdFromProperties === 'string' + ? sessionIdFromProperties + : `test-session-${Date.now()}`; + + return { + type, + properties: { + sessionID, + ...properties, + }, + }; +} + +export const mockEvents = { + sessionIdle: (sessionID?: string): PluginEvent => createMockEvent('session.idle', { sessionID }), + + sessionError: (sessionID?: string): PluginEvent => createMockEvent('session.error', { sessionID }), + + sessionCreated: (sessionID?: string): PluginEvent => + createMockEvent('session.created', { + sessionID, + info: { id: sessionID }, + }), + + permissionAsked: (id?: string, sessionID?: string): PluginEvent => + createMockEvent('permission.asked', { + id: id ?? `perm-${Date.now()}`, + sessionID, + }), + + permissionReplied: (requestID: string, reply = 'once'): PluginEvent => + createMockEvent('permission.replied', { + requestID, + reply, + }), + + questionAsked: ( + id?: string, + sessionID?: string, + questions: Array> = [{ text: 'Test question?' }], + ): PluginEvent => + createMockEvent('question.asked', { + id: id ?? `q-${Date.now()}`, + sessionID, + questions, + }), + + questionReplied: (requestID: string, answers: Array> = [['answer']]): PluginEvent => + createMockEvent('question.replied', { + requestID, + answers, + }), + + questionRejected: (requestID: string): PluginEvent => + createMockEvent('question.rejected', { + requestID, + }), + + messageUpdated: (messageId?: string, role = 'user', sessionID?: string): PluginEvent => + createMockEvent('message.updated', { + sessionID, + info: { + id: messageId ?? `msg-${Date.now()}`, + role, + time: { created: Date.now() / 1000 }, + }, + }), +}; + +export function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function waitFor( + condition: () => boolean | Promise, + timeout = 5000, + interval = 50, +): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + const result = await condition(); + if (result) { + return; + } + await wait(interval); + } + + throw new Error(`Condition not met within ${timeout}ms`); +} + +beforeAll(() => { + if (!fs.existsSync(TEST_TEMP_BASE)) { + fs.mkdirSync(TEST_TEMP_BASE, { recursive: true }); + } +}); + +afterAll(() => { + try { + const contents = fs.readdirSync(TEST_TEMP_BASE); + if (contents.length === 0) { + fs.rmdirSync(TEST_TEMP_BASE); + } + } catch { + // Ignore cleanup errors. + } +}); + +beforeEach(() => { + process.env.NODE_ENV = 'test'; +}); + +afterEach(() => { + cleanupTestTempDir(); +}); + +export function createConsoleCapture(): ConsoleCapture { + const logs: ConsoleCaptureStore = { + log: [], + warn: [], + error: [], + info: [], + debug: [], + }; + + const original: Record void> = { + log: console.log, + warn: console.warn, + error: console.error, + info: console.info, + debug: console.debug, + }; + + let capturing = false; + const methods = Object.keys(original) as ConsoleMethod[]; + const consoleRef = console as unknown as Record void>; + const get = ((type?: ConsoleMethod) => (type ? logs[type] : logs)) as ConsoleCapture['get']; + + return { + start() { + if (capturing) { + return; + } + capturing = true; + + for (const type of methods) { + consoleRef[type] = (...args: unknown[]) => { + logs[type].push(args); + }; + } + }, + + stop() { + if (!capturing) { + return; + } + capturing = false; + + for (const [type, fn] of Object.entries(original) as Array<[ + ConsoleMethod, + (...args: unknown[]) => void, + ]>) { + consoleRef[type] = fn; + } + }, + + get, + + clear() { + for (const type of methods) { + logs[type].length = 0; + } + }, + }; +} + +export const platform = os.platform(); +export const isWindows = platform === 'win32'; +export const isMacOS = platform === 'darwin'; +export const isLinux = platform === 'linux'; + +export function getTTSCalls(shell: MockShellRunner): ShellCallRecord[] { + return shell.getCalls().filter((record) => { + const cmd = record.command; + + if (cmd.includes('powershell.exe') && cmd.includes('-File') && cmd.includes('.ps1')) { + return true; + } + + if (cmd.includes('paplay') || cmd.includes('aplay') || cmd.includes('afplay')) { + return true; + } + + if (cmd.includes('say ')) { + return true; + } + + if (cmd.includes('System.Windows.Media.MediaPlayer')) { + return true; + } + + return false; + }); +} + +export function getAudioCalls(shell: MockShellRunner): ShellCallRecord[] { + return shell.getCalls().filter((record) => { + const cmd = record.command; + + if (cmd.includes('System.Windows.Media.MediaPlayer')) { + return true; + } + + if (cmd.includes('powershell.exe') && cmd.includes('-File') && cmd.includes('.ps1')) { + return true; + } + + if (cmd.includes('edge-tts') && cmd.includes('--write-media')) { + return true; + } + + if (cmd.includes('paplay') || cmd.includes('aplay')) { + return true; + } + + if (cmd.includes('afplay')) { + return true; + } + + if (cmd.includes('say ')) { + return true; + } + + return false; + }); +} + +export function getTestTTSEngine(): 'sapi' | 'edge' { + return isWindows ? 'sapi' : 'edge'; +} + +export function wasTTSCalled(shell: MockShellRunner): boolean { + return getTTSCalls(shell).length > 0; +} + +export default { + createTestTempDir, + cleanupTestTempDir, + getTestTempDir, + createTestConfig, + createMinimalConfig, + createTestAssets, + createTestLogsDir, + readTestFile, + testFileExists, + createMockShellRunner, + createMockClient, + createMockEvent, + mockEvents, + wait, + waitFor, + createConsoleCapture, + platform, + isWindows, + isMacOS, + isLinux, + getTTSCalls, + getAudioCalls, + getTestTTSEngine, + wasTTSCalled, +}; diff --git a/tests/unit/ai-messages.test.js b/tests/unit/ai-messages.test.ts similarity index 99% rename from tests/unit/ai-messages.test.js rename to tests/unit/ai-messages.test.ts index 9f7570c..a13ab6f 100644 --- a/tests/unit/ai-messages.test.js +++ b/tests/unit/ai-messages.test.ts @@ -1,5 +1,6 @@ +// @ts-nocheck import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test'; -import { generateAIMessage, getSmartMessage, testAIConnection } from '../../util/ai-messages.js'; +import { generateAIMessage, getSmartMessage, testAIConnection } from '../../src/util/ai-messages.js'; import { createTestTempDir, cleanupTestTempDir, createTestConfig } from '../setup.js'; describe('AI Message Generation Module', () => { @@ -396,4 +397,4 @@ describe('AI Message Generation Module', () => { expect(body.messages[1].content).toContain('TestProject'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/config-load.test.js b/tests/unit/config-load.test.ts similarity index 98% rename from tests/unit/config-load.test.js rename to tests/unit/config-load.test.ts index 6efe92a..c37076e 100644 --- a/tests/unit/config-load.test.js +++ b/tests/unit/config-load.test.ts @@ -1,7 +1,8 @@ +// @ts-nocheck import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import fs from 'fs'; import path from 'path'; -import { loadConfig, parseJSONC } from '../../util/config.js'; +import { loadConfig, parseJSONC } from '../../src/util/config.js'; import { createTestTempDir, cleanupTestTempDir, diff --git a/tests/unit/config-update.test.ts b/tests/unit/config-update.test.ts new file mode 100644 index 0000000..6cd5996 --- /dev/null +++ b/tests/unit/config-update.test.ts @@ -0,0 +1,813 @@ +// @ts-nocheck +/** + * Unit Tests for Config Update Logic + * + * Verifies that the config updater: + * 1. Preserves user values when regenerating the config file + * 2. Updates comments without affecting stored values + * 3. Correctly handles the focus detection configuration section + * + * @see src/util/config.ts - generateDefaultConfig, loadConfig, deepMerge + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { + createTestTempDir, + cleanupTestTempDir, + createTestConfig, + createTestAssets, + readTestFile, +} from '../setup.js'; +import fs from 'fs'; +import path from 'path'; + +describe('config update logic', () => { + let loadConfig; + let parseJSONC; + let deepMerge; + let findNewFields; + let getDefaultConfigObject; + let formatJSON; + + beforeEach(async () => { + createTestTempDir(); + createTestAssets(); + + const module = await import('../../src/util/config.js'); + loadConfig = module.loadConfig; + parseJSONC = module.parseJSONC; + deepMerge = module.deepMerge; + findNewFields = module.findNewFields; + getDefaultConfigObject = module.getDefaultConfigObject; + formatJSON = module.formatJSON; + }); + + afterEach(() => { + cleanupTestTempDir(); + }); + + // ============================================================ + // FOCUS DETECTION CONFIGURATION (suppressWhenFocused / alwaysNotify) + // ============================================================ + + describe('suppressWhenFocused config', () => { + test('defaults to false when no config file exists', () => { + const config = loadConfig('smart-voice-notify'); + expect(config.suppressWhenFocused).toBe(false); + }); + + test('defaults to false when config file exists without the field', () => { + createTestConfig({ + _configVersion: '1.0.0', + enabled: true, + notificationMode: 'sound-first', + }); + + const config = loadConfig('smart-voice-notify'); + expect(config.suppressWhenFocused).toBe(false); + }); + + test('preserves user value when set to true', () => { + createTestConfig({ + _configVersion: '1.0.0', + suppressWhenFocused: true, + }); + + const config = loadConfig('smart-voice-notify'); + expect(config.suppressWhenFocused).toBe(true); + }); + + test('preserves user value when explicitly set to false', () => { + createTestConfig({ + _configVersion: '1.0.0', + suppressWhenFocused: false, + }); + + const config = loadConfig('smart-voice-notify'); + expect(config.suppressWhenFocused).toBe(false); + }); + + test('value survives config regeneration on version change', () => { + createTestConfig({ + _configVersion: '0.0.1', + suppressWhenFocused: true, + }); + + const config = loadConfig('smart-voice-notify'); + expect(config.suppressWhenFocused).toBe(true); + + // Verify the regenerated file also has the value + const content = readTestFile('smart-voice-notify.jsonc'); + expect(content).toContain('"suppressWhenFocused": true'); + }); + + test('appears in generated config file under FOCUS DETECTION section', () => { + loadConfig('smart-voice-notify'); + + const content = readTestFile('smart-voice-notify.jsonc'); + expect(content).toContain('FOCUS DETECTION SETTINGS'); + expect(content).toContain('"suppressWhenFocused"'); + }); + }); + + describe('alwaysNotify config', () => { + test('defaults to false when no config file exists', () => { + const config = loadConfig('smart-voice-notify'); + expect(config.alwaysNotify).toBe(false); + }); + + test('defaults to false when config file exists without the field', () => { + createTestConfig({ + _configVersion: '1.0.0', + enabled: true, + }); + + const config = loadConfig('smart-voice-notify'); + expect(config.alwaysNotify).toBe(false); + }); + + test('preserves user value when set to true', () => { + createTestConfig({ + _configVersion: '1.0.0', + alwaysNotify: true, + }); + + const config = loadConfig('smart-voice-notify'); + expect(config.alwaysNotify).toBe(true); + }); + + test('preserves user value when explicitly set to false', () => { + createTestConfig({ + _configVersion: '1.0.0', + alwaysNotify: false, + }); + + const config = loadConfig('smart-voice-notify'); + expect(config.alwaysNotify).toBe(false); + }); + + test('value survives config regeneration on version change', () => { + createTestConfig({ + _configVersion: '0.0.1', + alwaysNotify: true, + }); + + const config = loadConfig('smart-voice-notify'); + expect(config.alwaysNotify).toBe(true); + + const content = readTestFile('smart-voice-notify.jsonc'); + expect(content).toContain('"alwaysNotify": true'); + }); + }); + + describe('suppressWhenFocused and alwaysNotify interaction', () => { + test('both can be set simultaneously', () => { + createTestConfig({ + _configVersion: '1.0.0', + suppressWhenFocused: true, + alwaysNotify: true, + }); + + const config = loadConfig('smart-voice-notify'); + expect(config.suppressWhenFocused).toBe(true); + expect(config.alwaysNotify).toBe(true); + }); + + test('both are preserved through version update', () => { + createTestConfig({ + _configVersion: '0.0.1', + suppressWhenFocused: true, + alwaysNotify: false, + }); + + const config = loadConfig('smart-voice-notify'); + expect(config.suppressWhenFocused).toBe(true); + expect(config.alwaysNotify).toBe(false); + }); + + test('focus detection fields present in getDefaultConfigObject', () => { + const defaults = getDefaultConfigObject(); + expect(defaults).toHaveProperty('suppressWhenFocused'); + expect(defaults).toHaveProperty('alwaysNotify'); + expect(defaults.suppressWhenFocused).toBe(false); + expect(defaults.alwaysNotify).toBe(false); + }); + }); + + // ============================================================ + // CONFIG REGENERATION: USER VALUE PRESERVATION + // ============================================================ + + describe('config regeneration preserves user values', () => { + test('preserves boolean false values during version-triggered regeneration', () => { + // User has set several booleans to non-default values + createTestConfig({ + _configVersion: '0.0.1', + enabled: false, + enableTTS: false, + enableSound: false, + enableToast: false, + enableDesktopNotification: false, + suppressWhenFocused: true, + }); + + const config = loadConfig('smart-voice-notify'); + + // All user-set false values must survive + expect(config.enabled).toBe(false); + expect(config.enableTTS).toBe(false); + expect(config.enableSound).toBe(false); + expect(config.enableToast).toBe(false); + expect(config.enableDesktopNotification).toBe(false); + expect(config.suppressWhenFocused).toBe(true); + }); + + test('preserves numeric zero values during regeneration', () => { + createTestConfig({ + _configVersion: '0.0.1', + desktopNotificationTimeout: 0, + volumeThreshold: 0, + projectSoundSeed: 0, + maxFollowUpReminders: 0, + }); + + const config = loadConfig('smart-voice-notify'); + + expect(config.desktopNotificationTimeout).toBe(0); + expect(config.volumeThreshold).toBe(0); + expect(config.projectSoundSeed).toBe(0); + expect(config.maxFollowUpReminders).toBe(0); + }); + + test('preserves empty string values during regeneration', () => { + createTestConfig({ + _configVersion: '0.0.1', + webhookUrl: '', + soundThemeDir: '', + aiApiKey: '', + }); + + const config = loadConfig('smart-voice-notify'); + + expect(config.webhookUrl).toBe(''); + expect(config.soundThemeDir).toBe(''); + expect(config.aiApiKey).toBe(''); + }); + + test('preserves custom string values during regeneration', () => { + createTestConfig({ + _configVersion: '0.0.1', + ttsEngine: 'edge', + edgeVoice: 'en-US-AnaNeural', + edgePitch: '+50Hz', + edgeRate: '+20%', + webhookUrl: 'https://discord.com/api/webhooks/test', + webhookUsername: 'My Custom Bot', + }); + + const config = loadConfig('smart-voice-notify'); + + expect(config.ttsEngine).toBe('edge'); + expect(config.edgeVoice).toBe('en-US-AnaNeural'); + expect(config.edgePitch).toBe('+50Hz'); + expect(config.edgeRate).toBe('+20%'); + expect(config.webhookUrl).toBe('https://discord.com/api/webhooks/test'); + expect(config.webhookUsername).toBe('My Custom Bot'); + }); + + test('preserves custom numeric values during regeneration', () => { + createTestConfig({ + _configVersion: '0.0.1', + ttsReminderDelaySeconds: 120, + idleReminderDelaySeconds: 90, + permissionReminderDelaySeconds: 45, + reminderBackoffMultiplier: 2.5, + elevenLabsStability: 0.8, + elevenLabsSimilarity: 0.9, + openaiTtsSpeed: 1.5, + }); + + const config = loadConfig('smart-voice-notify'); + + expect(config.ttsReminderDelaySeconds).toBe(120); + expect(config.idleReminderDelaySeconds).toBe(90); + expect(config.permissionReminderDelaySeconds).toBe(45); + expect(config.reminderBackoffMultiplier).toBe(2.5); + expect(config.elevenLabsStability).toBe(0.8); + expect(config.elevenLabsSimilarity).toBe(0.9); + expect(config.openaiTtsSpeed).toBe(1.5); + }); + + test('preserves user arrays intact during regeneration (no merge)', () => { + const customIdle = ['My custom done message.']; + const customPermission = ['Custom perm 1', 'Custom perm 2']; + + createTestConfig({ + _configVersion: '0.0.1', + idleTTSMessages: customIdle, + permissionTTSMessages: customPermission, + }); + + const config = loadConfig('smart-voice-notify'); + + expect(config.idleTTSMessages).toEqual(customIdle); + expect(config.idleTTSMessages).toHaveLength(1); + expect(config.permissionTTSMessages).toEqual(customPermission); + expect(config.permissionTTSMessages).toHaveLength(2); + }); + + test('preserves nested aiPrompts during regeneration', () => { + const customPrompts = { + idle: 'Say task is done in pirate speak.', + permission: 'Request permission urgently.', + question: 'Default question prompt', + error: 'Default error prompt', + idleReminder: 'Default idle reminder', + permissionReminder: 'Default perm reminder', + questionReminder: 'Default question reminder', + errorReminder: 'Default error reminder', + }; + + createTestConfig({ + _configVersion: '0.0.1', + aiPrompts: customPrompts, + }); + + const config = loadConfig('smart-voice-notify'); + + expect(config.aiPrompts.idle).toBe('Say task is done in pirate speak.'); + expect(config.aiPrompts.permission).toBe('Request permission urgently.'); + }); + + test('adds new fields from defaults while preserving all user values', () => { + // Simulate old config missing many fields + createTestConfig({ + _configVersion: '0.0.1', + enabled: false, + notificationMode: 'both', + ttsEngine: 'sapi', + ttsReminderDelaySeconds: 99, + enableTTS: false, + }); + + const config = loadConfig('smart-voice-notify'); + + // User values preserved + expect(config.enabled).toBe(false); + expect(config.notificationMode).toBe('both'); + expect(config.ttsEngine).toBe('sapi'); + expect(config.ttsReminderDelaySeconds).toBe(99); + expect(config.enableTTS).toBe(false); + + // New fields added with defaults + expect(config.suppressWhenFocused).toBe(false); + expect(config.alwaysNotify).toBe(false); + expect(config.enableDesktopNotification).toBe(true); + expect(config.enableWebhook).toBe(false); + expect(config.questionReminderDelaySeconds).toBe(25); + expect(config.errorReminderDelaySeconds).toBe(20); + }); + + test('version is updated after regeneration', () => { + createTestConfig({ + _configVersion: '0.0.1', + enabled: true, + suppressWhenFocused: true, + }); + + const config = loadConfig('smart-voice-notify'); + + // Version should be updated to current package.json version + expect(config._configVersion).not.toBe('0.0.1'); + expect(typeof config._configVersion).toBe('string'); + + // Verify in the regenerated file too + const content = readTestFile('smart-voice-notify.jsonc'); + const parsed = parseJSONC(content); + expect(parsed._configVersion).not.toBe('0.0.1'); + }); + }); + + // ============================================================ + // COMMENT UPDATES WITHOUT VALUE LOSS + // ============================================================ + + describe('comment updates without affecting values', () => { + test('regenerated file contains documentation comments', () => { + createTestConfig({ + _configVersion: '0.0.1', + enabled: false, + suppressWhenFocused: true, + }); + + loadConfig('smart-voice-notify'); + + const content = readTestFile('smart-voice-notify.jsonc'); + + // Comments are present in the regenerated file + expect(content).toContain('// '); + expect(content).toContain('PLUGIN ENABLE/DISABLE'); + expect(content).toContain('FOCUS DETECTION SETTINGS'); + expect(content).toContain('TTS ENGINE SELECTION'); + expect(content).toContain('GENERAL SETTINGS'); + expect(content).toContain('WEBHOOK NOTIFICATION SETTINGS'); + }); + + test('user values are embedded in regenerated commented file', () => { + createTestConfig({ + _configVersion: '0.0.1', + enabled: false, + notificationMode: 'tts-first', + ttsEngine: 'edge', + edgeVoice: 'en-US-AnaNeural', + suppressWhenFocused: true, + alwaysNotify: false, + desktopNotificationTimeout: 15, + }); + + loadConfig('smart-voice-notify'); + + const content = readTestFile('smart-voice-notify.jsonc'); + + // User values must be in the regenerated file + expect(content).toContain('"enabled": false'); + expect(content).toContain('"notificationMode": "tts-first"'); + expect(content).toContain('"ttsEngine": "edge"'); + expect(content).toContain('"edgeVoice": "en-US-AnaNeural"'); + expect(content).toContain('"suppressWhenFocused": true'); + expect(content).toContain('"alwaysNotify": false'); + expect(content).toContain('"desktopNotificationTimeout": 15'); + }); + + test('regenerated file is valid JSONC that parses correctly', () => { + createTestConfig({ + _configVersion: '0.0.1', + enabled: false, + suppressWhenFocused: true, + ttsReminderDelaySeconds: 42, + }); + + loadConfig('smart-voice-notify'); + + const content = readTestFile('smart-voice-notify.jsonc'); + + // Must parse without error + const parsed = parseJSONC(content); + expect(parsed).toBeDefined(); + expect(parsed.enabled).toBe(false); + expect(parsed.suppressWhenFocused).toBe(true); + expect(parsed.ttsReminderDelaySeconds).toBe(42); + }); + + test('re-loading regenerated config produces identical values', () => { + // First load: creates config from user values + defaults + createTestConfig({ + _configVersion: '0.0.1', + enabled: false, + notificationMode: 'both', + suppressWhenFocused: true, + alwaysNotify: true, + ttsReminderDelaySeconds: 60, + }); + + const firstLoad = loadConfig('smart-voice-notify'); + + // Second load: reads the regenerated file + const secondLoad = loadConfig('smart-voice-notify'); + + // All values must be identical + expect(secondLoad.enabled).toBe(firstLoad.enabled); + expect(secondLoad.notificationMode).toBe(firstLoad.notificationMode); + expect(secondLoad.suppressWhenFocused).toBe(firstLoad.suppressWhenFocused); + expect(secondLoad.alwaysNotify).toBe(firstLoad.alwaysNotify); + expect(secondLoad.ttsReminderDelaySeconds).toBe(firstLoad.ttsReminderDelaySeconds); + expect(secondLoad.enableDesktopNotification).toBe(firstLoad.enableDesktopNotification); + expect(secondLoad.enableTTS).toBe(firstLoad.enableTTS); + expect(secondLoad.enableSound).toBe(firstLoad.enableSound); + }); + + test('config file is NOT rewritten when version matches (no unnecessary writes)', () => { + // Create a config that already matches current version + const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8')); + const currentVersion = pkg.version; + + const defaults = getDefaultConfigObject(); + + // Write a full config with current version so no update is needed + createTestConfig({ + ...defaults, + _configVersion: currentVersion, + }); + + const tempDir = process.env.OPENCODE_CONFIG_DIR; + const configPath = path.join(tempDir, 'smart-voice-notify.jsonc'); + const mtimeBefore = fs.statSync(configPath).mtimeMs; + + // Small delay to ensure mtime would differ if file was written + const startTime = Date.now(); + while (Date.now() - startTime < 50) { + // busy-wait for timestamp granularity + } + + loadConfig('smart-voice-notify'); + + const mtimeAfter = fs.statSync(configPath).mtimeMs; + + // File should NOT have been rewritten since version matches and no new fields + expect(mtimeAfter).toBe(mtimeBefore); + }); + }); + + // ============================================================ + // DEEP MERGE EDGE CASES FOR CONFIG UPDATE + // ============================================================ + + describe('deepMerge edge cases for config update', () => { + test('user value of different type than default takes precedence', () => { + // User sets a string where default is boolean (unusual but possible) + const defaults = { enabled: true }; + const user = { enabled: 'disabled' }; + const result = deepMerge(defaults, user); + expect(result.enabled).toBe('disabled'); + }); + + test('deeply nested objects are recursively merged', () => { + const defaults = { + level1: { + level2: { + a: 1, + b: 2, + }, + }, + }; + const user = { + level1: { + level2: { + a: 99, + }, + }, + }; + + const result = deepMerge(defaults, user); + expect(result.level1.level2.a).toBe(99); + expect(result.level1.level2.b).toBe(2); + }); + + test('user array completely replaces default array regardless of length', () => { + const defaults = { messages: ['a', 'b', 'c', 'd', 'e'] }; + const user = { messages: ['only-one'] }; + const result = deepMerge(defaults, user); + expect(result.messages).toEqual(['only-one']); + }); + + test('empty user array replaces default array', () => { + const defaults = { messages: ['a', 'b', 'c'] }; + const user = { messages: [] }; + const result = deepMerge(defaults, user); + expect(result.messages).toEqual([]); + }); + + test('user extra keys not in defaults are preserved', () => { + const defaults = { a: 1 }; + const user = { a: 2, customKey: 'user-added' }; + const result = deepMerge(defaults, user); + expect(result.a).toBe(2); + expect(result.customKey).toBe('user-added'); + }); + + test('handles empty default object', () => { + const defaults = {}; + const user = { a: 1, b: 2 }; + const result = deepMerge(defaults, user); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + test('handles empty user object', () => { + const defaults = { a: 1, b: 2 }; + const user = {}; + const result = deepMerge(defaults, user); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + test('primitive default with object user returns user', () => { + const defaults = 'string-default'; + const user = { complex: true }; + const result = deepMerge(defaults, user); + expect(result).toEqual({ complex: true }); + }); + }); + + // ============================================================ + // findNewFields FOR CONFIG UPDATE DETECTION + // ============================================================ + + describe('findNewFields for config update detection', () => { + test('detects focus detection fields missing from old config', () => { + const defaults = getDefaultConfigObject(); + const oldConfig = { + enabled: true, + notificationMode: 'sound-first', + // Missing: suppressWhenFocused, alwaysNotify, and many others + }; + + const newFields = findNewFields(defaults, oldConfig); + + expect(newFields).toContain('suppressWhenFocused'); + expect(newFields).toContain('alwaysNotify'); + }); + + test('returns empty array when config has all fields', () => { + const defaults = getDefaultConfigObject(); + + // Config that has every key from defaults + const fullConfig = { ...defaults }; + + const newFields = findNewFields(defaults, fullConfig); + expect(newFields).toEqual([]); + }); + + test('detects nested missing fields in aiPrompts', () => { + const defaults = getDefaultConfigObject(); + const partialConfig = { + ...defaults, + aiPrompts: { + idle: 'custom', + // Missing: permission, question, error, and all reminders + }, + }; + + const newFields = findNewFields(defaults, partialConfig); + + expect(newFields).toContain('aiPrompts.permission'); + expect(newFields).toContain('aiPrompts.question'); + expect(newFields).toContain('aiPrompts.error'); + expect(newFields).toContain('aiPrompts.idleReminder'); + }); + + test('does not flag array fields as having nested missing items', () => { + const defaults = { messages: ['a', 'b', 'c'] }; + const user = { messages: ['x'] }; + + const newFields = findNewFields(defaults, user); + // Arrays are not recursed into + expect(newFields).toEqual([]); + }); + + test('handles non-object defaults gracefully', () => { + const result = findNewFields('not-object', { a: 1 }); + expect(result).toEqual([]); + }); + + test('throws when user is non-object (string passed to `in` operator)', () => { + // findNewFields only guards defaults being non-object, not user. + // Passing a primitive as user causes `key in userRecord` to throw. + expect(() => findNewFields({ a: 1 }, 'not-object')).toThrow(); + }); + }); + + // ============================================================ + // FULL CONFIG ROUNDTRIP (REGENERATION + RELOAD) + // ============================================================ + + describe('full config roundtrip', () => { + test('comprehensive user config survives regeneration roundtrip', () => { + // Create a heavily customized config with an old version + const userConfig = { + _configVersion: '0.0.1', + enabled: false, + notificationMode: 'tts-first', + enableTTSReminder: false, + enableIdleNotification: false, + enablePermissionNotification: true, + enableQuestionNotification: false, + enableErrorNotification: true, + enableIdleReminder: false, + enablePermissionReminder: true, + enableQuestionReminder: false, + enableErrorReminder: true, + ttsReminderDelaySeconds: 120, + idleReminderDelaySeconds: 90, + permissionReminderDelaySeconds: 45, + questionReminderDelaySeconds: 50, + errorReminderDelaySeconds: 35, + enableFollowUpReminders: false, + maxFollowUpReminders: 5, + reminderBackoffMultiplier: 3.0, + ttsEngine: 'edge', + enableTTS: false, + elevenLabsVoiceId: 'custom-voice-id', + edgeVoice: 'en-US-AnaNeural', + edgePitch: '+50Hz', + edgeRate: '-10%', + suppressWhenFocused: true, + alwaysNotify: false, + enableDesktopNotification: false, + desktopNotificationTimeout: 20, + showProjectInNotification: false, + enableWebhook: true, + webhookUrl: 'https://hooks.example.com/test', + webhookUsername: 'TestBot', + webhookEvents: ['idle', 'error'], + webhookMentionOnPermission: true, + enableSound: false, + enableToast: false, + wakeMonitor: false, + forceVolume: true, + volumeThreshold: 80, + debugLog: true, + }; + + createTestConfig(userConfig); + + // Load triggers regeneration (version changed) + const config = loadConfig('smart-voice-notify'); + + // Verify every single user value survived + expect(config.enabled).toBe(false); + expect(config.notificationMode).toBe('tts-first'); + expect(config.enableTTSReminder).toBe(false); + expect(config.enableIdleNotification).toBe(false); + expect(config.enablePermissionNotification).toBe(true); + expect(config.enableQuestionNotification).toBe(false); + expect(config.enableErrorNotification).toBe(true); + expect(config.enableIdleReminder).toBe(false); + expect(config.enablePermissionReminder).toBe(true); + expect(config.enableQuestionReminder).toBe(false); + expect(config.enableErrorReminder).toBe(true); + expect(config.ttsReminderDelaySeconds).toBe(120); + expect(config.idleReminderDelaySeconds).toBe(90); + expect(config.permissionReminderDelaySeconds).toBe(45); + expect(config.questionReminderDelaySeconds).toBe(50); + expect(config.errorReminderDelaySeconds).toBe(35); + expect(config.enableFollowUpReminders).toBe(false); + expect(config.maxFollowUpReminders).toBe(5); + expect(config.reminderBackoffMultiplier).toBe(3.0); + expect(config.ttsEngine).toBe('edge'); + expect(config.enableTTS).toBe(false); + expect(config.elevenLabsVoiceId).toBe('custom-voice-id'); + expect(config.edgeVoice).toBe('en-US-AnaNeural'); + expect(config.edgePitch).toBe('+50Hz'); + expect(config.edgeRate).toBe('-10%'); + expect(config.suppressWhenFocused).toBe(true); + expect(config.alwaysNotify).toBe(false); + expect(config.enableDesktopNotification).toBe(false); + expect(config.desktopNotificationTimeout).toBe(20); + expect(config.showProjectInNotification).toBe(false); + expect(config.enableWebhook).toBe(true); + expect(config.webhookUrl).toBe('https://hooks.example.com/test'); + expect(config.webhookUsername).toBe('TestBot'); + expect(config.webhookEvents).toEqual(['idle', 'error']); + expect(config.webhookMentionOnPermission).toBe(true); + expect(config.enableSound).toBe(false); + expect(config.enableToast).toBe(false); + expect(config.wakeMonitor).toBe(false); + expect(config.forceVolume).toBe(true); + expect(config.volumeThreshold).toBe(80); + expect(config.debugLog).toBe(true); + }); + + test('regenerated file can be parsed back and yields same config', () => { + createTestConfig({ + _configVersion: '0.0.1', + enabled: false, + suppressWhenFocused: true, + notificationMode: 'both', + ttsReminderDelaySeconds: 77, + idleTTSMessages: ['Custom only message'], + }); + + const config = loadConfig('smart-voice-notify'); + + // Parse the regenerated file + const content = readTestFile('smart-voice-notify.jsonc'); + const parsed = parseJSONC(content); + + // Key values match + expect(parsed.enabled).toBe(config.enabled); + expect(parsed.suppressWhenFocused).toBe(config.suppressWhenFocused); + expect(parsed.notificationMode).toBe(config.notificationMode); + expect(parsed.ttsReminderDelaySeconds).toBe(config.ttsReminderDelaySeconds); + expect(parsed.idleTTSMessages).toEqual(config.idleTTSMessages); + }); + + test('multiple sequential loads produce stable config', () => { + createTestConfig({ + _configVersion: '0.0.1', + enabled: false, + suppressWhenFocused: true, + }); + + const first = loadConfig('smart-voice-notify'); + const second = loadConfig('smart-voice-notify'); + const third = loadConfig('smart-voice-notify'); + + // All loads should produce the same values + expect(first.enabled).toBe(second.enabled); + expect(second.enabled).toBe(third.enabled); + expect(first.suppressWhenFocused).toBe(second.suppressWhenFocused); + expect(second.suppressWhenFocused).toBe(third.suppressWhenFocused); + expect(first._configVersion).toBe(second._configVersion); + expect(second._configVersion).toBe(third._configVersion); + }); + }); +}); diff --git a/tests/unit/config.test.js b/tests/unit/config.test.ts similarity index 99% rename from tests/unit/config.test.js rename to tests/unit/config.test.ts index a20b5e1..bae1bd1 100644 --- a/tests/unit/config.test.js +++ b/tests/unit/config.test.ts @@ -1,10 +1,11 @@ +// @ts-nocheck /** * Unit Tests for Configuration Module * * Tests for util/config.js configuration loading and merging functionality. * Focuses on Task 1.7: Testing new desktop notification config fields. * - * @see util/config.js + * @see src/util/config.js * @see docs/ARCHITECT_PLAN.md - Phase 1, Task 1.7 */ @@ -33,7 +34,7 @@ describe('config module', () => { createTestAssets(); // Fresh import of the module (loadConfig uses OPENCODE_CONFIG_DIR env var) - const module = await import('../../util/config.js'); + const module = await import('../../src/util/config.js'); loadConfig = module.loadConfig; parseJSONC = module.parseJSONC; deepMerge = module.deepMerge; diff --git a/tests/unit/desktop-notify.test.js b/tests/unit/desktop-notify.test.ts similarity index 99% rename from tests/unit/desktop-notify.test.js rename to tests/unit/desktop-notify.test.ts index 1107b39..fbf0e71 100644 --- a/tests/unit/desktop-notify.test.js +++ b/tests/unit/desktop-notify.test.ts @@ -1,10 +1,11 @@ +// @ts-nocheck /** * Unit Tests for Desktop Notification Module * * Tests for util/desktop-notify.js cross-platform desktop notification functionality. * Uses mocked node-notifier to avoid actual notifications during tests. * - * @see util/desktop-notify.js + * @see src/util/desktop-notify.js * @see docs/ARCHITECT_PLAN.md - Phase 1, Task 1.6 */ @@ -58,7 +59,7 @@ describe('desktop-notify module', () => { createTestLogsDir(); // Fresh import of the module - const module = await import('../../util/desktop-notify.js'); + const module = await import('../../src/util/desktop-notify.js'); desktopNotify = module.default; sendDesktopNotification = module.sendDesktopNotification; notifyTaskComplete = module.notifyTaskComplete; diff --git a/tests/unit/error-handler.test.js b/tests/unit/error-handler.test.ts similarity index 98% rename from tests/unit/error-handler.test.js rename to tests/unit/error-handler.test.ts index d946b03..529b30d 100644 --- a/tests/unit/error-handler.test.js +++ b/tests/unit/error-handler.test.ts @@ -1,10 +1,11 @@ +// @ts-nocheck /** * Unit Tests for Error Handler Functionality * * Tests for the session.error event handling and getErrorMessage() helper function. * These tests verify error notifications work correctly in the plugin. * - * @see index.js - session.error event handler and getErrorMessage() + * @see src/index.ts - session.error event handler and getErrorMessage() * @see docs/ARCHITECT_PLAN.md - Phase 2, Task 2.5 */ @@ -64,7 +65,7 @@ describe('error handler functionality', () => { }); // Fresh import of config module - const configModule = await import('../../util/config.js'); + const configModule = await import('../../src/util/config.js'); loadConfig = configModule.loadConfig; config = loadConfig('smart-voice-notify'); }); @@ -371,7 +372,7 @@ describe('error handler functionality', () => { let notifyError; beforeEach(async () => { - const module = await import('../../util/desktop-notify.js'); + const module = await import('../../src/util/desktop-notify.js'); notifyError = module.notifyError; }); @@ -551,7 +552,7 @@ describe('error handler functionality', () => { createTestAssets(); // Don't create custom config - let defaults load - const module = await import('../../util/config.js'); + const module = await import('../../src/util/config.js'); loadConfig = module.loadConfig; defaultConfig = loadConfig('smart-voice-notify'); }); diff --git a/tests/unit/focus-detect.test.js b/tests/unit/focus-detect.test.js deleted file mode 100644 index 305a096..0000000 --- a/tests/unit/focus-detect.test.js +++ /dev/null @@ -1,605 +0,0 @@ -/** - * Unit Tests for Focus Detection Module - * - * Tests for the util/focus-detect.js module which provides terminal focus detection. - * Used to suppress notifications when the user is actively looking at the terminal. - * - * @see util/focus-detect.js - * @see docs/ARCHITECT_PLAN.md - Phase 3, Task 3.6 - */ - -import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; -import { - createTestTempDir, - cleanupTestTempDir, - createTestLogsDir, - readTestFile, - testFileExists, - wait -} from '../setup.js'; - -// Import the focus detection module -import { - isTerminalFocused, - isFocusDetectionSupported, - getTerminalName, - getPlatform, - clearFocusCache, - resetTerminalDetection, - getCacheState, - KNOWN_TERMINALS_MACOS -} from '../../util/focus-detect.js'; - -import focusDetect from '../../util/focus-detect.js'; - -describe('focus detection module', () => { - beforeEach(() => { - // Create test temp directory and reset caches before each test - createTestTempDir(); - clearFocusCache(); - resetTerminalDetection(); - }); - - afterEach(() => { - cleanupTestTempDir(); - clearFocusCache(); - resetTerminalDetection(); - }); - - // ============================================================ - // getPlatform() TESTS - // ============================================================ - - describe('getPlatform()', () => { - test('returns a string', () => { - const platform = getPlatform(); - expect(typeof platform).toBe('string'); - }); - - test('returns one of the known platforms', () => { - const platform = getPlatform(); - expect(['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'sunos', 'aix']).toContain(platform); - }); - - test('returns consistent value on repeated calls', () => { - const platform1 = getPlatform(); - const platform2 = getPlatform(); - expect(platform1).toBe(platform2); - }); - }); - - // ============================================================ - // isFocusDetectionSupported() TESTS - // ============================================================ - - describe('isFocusDetectionSupported()', () => { - test('returns an object', () => { - const result = isFocusDetectionSupported(); - expect(typeof result).toBe('object'); - }); - - test('returns object with supported property', () => { - const result = isFocusDetectionSupported(); - expect(result).toHaveProperty('supported'); - expect(typeof result.supported).toBe('boolean'); - }); - - test('returns reason when not supported', () => { - const result = isFocusDetectionSupported(); - // If not supported, should have a reason - if (!result.supported) { - expect(result).toHaveProperty('reason'); - expect(typeof result.reason).toBe('string'); - } - }); - - test('macOS should be supported', () => { - const platform = getPlatform(); - const result = isFocusDetectionSupported(); - - if (platform === 'darwin') { - expect(result.supported).toBe(true); - } - }); - - test('Windows should not be supported', () => { - const platform = getPlatform(); - const result = isFocusDetectionSupported(); - - if (platform === 'win32') { - expect(result.supported).toBe(false); - expect(result.reason).toContain('Windows'); - } - }); - - test('Linux should not be supported', () => { - const platform = getPlatform(); - const result = isFocusDetectionSupported(); - - if (platform === 'linux') { - expect(result.supported).toBe(false); - expect(result.reason).toContain('Linux'); - } - }); - }); - - // ============================================================ - // KNOWN_TERMINALS_MACOS TESTS - // ============================================================ - - describe('KNOWN_TERMINALS_MACOS', () => { - test('is an array', () => { - expect(Array.isArray(KNOWN_TERMINALS_MACOS)).toBe(true); - }); - - test('contains at least 20 terminal names', () => { - expect(KNOWN_TERMINALS_MACOS.length).toBeGreaterThanOrEqual(20); - }); - - test('includes Terminal (macOS default)', () => { - expect(KNOWN_TERMINALS_MACOS).toContain('Terminal'); - }); - - test('includes iTerm2', () => { - expect(KNOWN_TERMINALS_MACOS.some(t => t.includes('iTerm'))).toBe(true); - }); - - test('includes VS Code variants', () => { - expect(KNOWN_TERMINALS_MACOS.some(t => t.includes('Code'))).toBe(true); - }); - - test('includes popular terminals like Alacritty, Hyper, Warp', () => { - expect(KNOWN_TERMINALS_MACOS).toContain('Alacritty'); - expect(KNOWN_TERMINALS_MACOS).toContain('Hyper'); - expect(KNOWN_TERMINALS_MACOS).toContain('Warp'); - }); - - test('includes JetBrains IDEs', () => { - expect(KNOWN_TERMINALS_MACOS.some(t => t.includes('IntelliJ'))).toBe(true); - expect(KNOWN_TERMINALS_MACOS.some(t => t.includes('WebStorm'))).toBe(true); - }); - - test('all entries are non-empty strings', () => { - for (const terminal of KNOWN_TERMINALS_MACOS) { - expect(typeof terminal).toBe('string'); - expect(terminal.length).toBeGreaterThan(0); - } - }); - }); - - // ============================================================ - // isTerminalFocused() - BASIC TESTS - // ============================================================ - - describe('isTerminalFocused() basic behavior', () => { - test('returns a Promise', () => { - const result = isTerminalFocused(); - expect(result).toBeInstanceOf(Promise); - }); - - test('Promise resolves to a boolean', async () => { - const result = await isTerminalFocused(); - expect(typeof result).toBe('boolean'); - }); - - test('accepts empty options object', async () => { - const result = await isTerminalFocused({}); - expect(typeof result).toBe('boolean'); - }); - - test('accepts options with debugLog', async () => { - createTestLogsDir(); - const result = await isTerminalFocused({ debugLog: true }); - expect(typeof result).toBe('boolean'); - }); - - test('handles null options gracefully', async () => { - // Should not throw with null - const result = await isTerminalFocused(null); - expect(typeof result).toBe('boolean'); - }); - - test('handles undefined options gracefully', async () => { - const result = await isTerminalFocused(undefined); - expect(typeof result).toBe('boolean'); - }); - }); - - // ============================================================ - // isTerminalFocused() - PLATFORM-SPECIFIC BEHAVIOR - // ============================================================ - - describe('isTerminalFocused() platform behavior', () => { - test('returns false on unsupported platforms (fail-open)', async () => { - const platform = getPlatform(); - const supported = isFocusDetectionSupported(); - - if (!supported.supported) { - // On unsupported platforms, should return false (fail-open: still notify) - const result = await isTerminalFocused(); - expect(result).toBe(false); - } - }); - - test('returns boolean on Windows (fails open)', async () => { - const platform = getPlatform(); - - if (platform === 'win32') { - const result = await isTerminalFocused(); - expect(result).toBe(false); - } - }); - - test('returns boolean on Linux (fails open)', async () => { - const platform = getPlatform(); - - if (platform === 'linux') { - const result = await isTerminalFocused(); - expect(result).toBe(false); - } - }); - - test('handles macOS check without throwing', async () => { - const platform = getPlatform(); - - if (platform === 'darwin') { - // Should not throw - may return true or false depending on focused app - await expect(isTerminalFocused()).resolves.toBeDefined(); - } - }); - }); - - // ============================================================ - // CACHING BEHAVIOR TESTS - // ============================================================ - - describe('focus detection caching', () => { - test('getCacheState() returns cache object', () => { - const cache = getCacheState(); - expect(typeof cache).toBe('object'); - expect(cache).toHaveProperty('isFocused'); - expect(cache).toHaveProperty('timestamp'); - expect(cache).toHaveProperty('terminalName'); - }); - - test('cache starts with default values', () => { - clearFocusCache(); - const cache = getCacheState(); - expect(cache.isFocused).toBe(false); - expect(cache.timestamp).toBe(0); - expect(cache.terminalName).toBeNull(); - }); - - test('cache is updated after isTerminalFocused() call', async () => { - clearFocusCache(); - const cacheBefore = getCacheState(); - expect(cacheBefore.timestamp).toBe(0); - - await isTerminalFocused(); - - const cacheAfter = getCacheState(); - expect(cacheAfter.timestamp).toBeGreaterThan(0); - }); - - test('clearFocusCache() resets cache state', async () => { - // First make a call to populate cache - await isTerminalFocused(); - - const cachePopulated = getCacheState(); - expect(cachePopulated.timestamp).toBeGreaterThan(0); - - // Clear cache - clearFocusCache(); - - const cacheCleared = getCacheState(); - expect(cacheCleared.timestamp).toBe(0); - expect(cacheCleared.isFocused).toBe(false); - }); - - test('caching prevents multiple system calls within TTL', async () => { - clearFocusCache(); - - // First call populates cache - const start = Date.now(); - const result1 = await isTerminalFocused(); - const cache1 = getCacheState(); - - // Second call should use cache (no new timestamp) - const result2 = await isTerminalFocused(); - const cache2 = getCacheState(); - - // Third call should also use cache - const result3 = await isTerminalFocused(); - const cache3 = getCacheState(); - - // All results should be the same (from cache) - expect(result1).toBe(result2); - expect(result2).toBe(result3); - - // Timestamps should be the same (cache hit) - expect(cache2.timestamp).toBe(cache1.timestamp); - expect(cache3.timestamp).toBe(cache1.timestamp); - }); - - test('cache expires after TTL (500ms)', async () => { - clearFocusCache(); - - // First call populates cache - await isTerminalFocused(); - const cache1 = getCacheState(); - const timestamp1 = cache1.timestamp; - - // Wait for cache to expire (TTL is 500ms, wait 600ms to be safe) - await wait(600); - - // Next call should refresh cache - await isTerminalFocused(); - const cache2 = getCacheState(); - const timestamp2 = cache2.timestamp; - - // Timestamps should be different (cache miss, new system call) - expect(timestamp2).toBeGreaterThan(timestamp1); - expect(timestamp2 - timestamp1).toBeGreaterThanOrEqual(500); - }); - - test('multiple rapid calls use cached value', async () => { - clearFocusCache(); - - // Make 5 rapid calls - const results = await Promise.all([ - isTerminalFocused(), - isTerminalFocused(), - isTerminalFocused(), - isTerminalFocused(), - isTerminalFocused() - ]); - - // All should return the same value - const firstResult = results[0]; - for (const result of results) { - expect(result).toBe(firstResult); - } - }); - }); - - // ============================================================ - // TERMINAL DETECTION TESTS - // ============================================================ - - describe('getTerminalName()', () => { - test('returns string or null', () => { - const result = getTerminalName(); - expect(result === null || typeof result === 'string').toBe(true); - }); - - test('caches terminal detection result', () => { - resetTerminalDetection(); - - const result1 = getTerminalName(); - const result2 = getTerminalName(); - - // Should return the same value (cached) - expect(result1).toBe(result2); - }); - - test('accepts debug parameter', () => { - resetTerminalDetection(); - createTestLogsDir(); - - // Should not throw - const result = getTerminalName(true); - expect(result === null || typeof result === 'string').toBe(true); - }); - - test('resetTerminalDetection() clears cached value', () => { - // Populate cache - getTerminalName(); - - // Reset - resetTerminalDetection(); - - // Should work again without errors - const result = getTerminalName(); - expect(result === null || typeof result === 'string').toBe(true); - }); - }); - - // ============================================================ - // DEBUG LOGGING TESTS - // ============================================================ - - describe('debug logging', () => { - test('creates logs directory when debugLog is true', async () => { - // Ensure temp dir exists - createTestTempDir(); - - await isTerminalFocused({ debugLog: true }); - - // Check if logs directory was created - expect(testFileExists('logs')).toBe(true); - }); - - test('writes to debug log file when enabled', async () => { - createTestTempDir(); - - await isTerminalFocused({ debugLog: true }); - - // Check if log file exists - expect(testFileExists('logs/smart-voice-notify-debug.log')).toBe(true); - }); - - test('debug log contains focus detection entries', async () => { - createTestTempDir(); - - await isTerminalFocused({ debugLog: true }); - - const logContent = readTestFile('logs/smart-voice-notify-debug.log'); - if (logContent) { - expect(logContent).toContain('[focus-detect]'); - } - }); - - test('debug logging does not affect return value', async () => { - clearFocusCache(); - createTestTempDir(); - - const withDebug = await isTerminalFocused({ debugLog: true }); - - clearFocusCache(); - - const withoutDebug = await isTerminalFocused({ debugLog: false }); - - // Both should be the same type - expect(typeof withDebug).toBe('boolean'); - expect(typeof withoutDebug).toBe('boolean'); - }); - - test('no log file created when debugLog is false', async () => { - createTestTempDir(); - - await isTerminalFocused({ debugLog: false }); - - // Log file should not exist (directory might not even be created) - const logContent = readTestFile('logs/smart-voice-notify-debug.log'); - // Either no log or only contains entries from debug=true calls - expect(logContent === null || !logContent.includes('[focus-detect]') || logContent.includes('[focus-detect]')).toBe(true); - }); - }); - - // ============================================================ - // DEFAULT EXPORT TESTS - // ============================================================ - - describe('default export', () => { - test('exports all expected functions', () => { - expect(focusDetect).toHaveProperty('isTerminalFocused'); - expect(focusDetect).toHaveProperty('isFocusDetectionSupported'); - expect(focusDetect).toHaveProperty('getTerminalName'); - expect(focusDetect).toHaveProperty('getPlatform'); - expect(focusDetect).toHaveProperty('clearFocusCache'); - expect(focusDetect).toHaveProperty('resetTerminalDetection'); - expect(focusDetect).toHaveProperty('getCacheState'); - expect(focusDetect).toHaveProperty('KNOWN_TERMINALS_MACOS'); - }); - - test('default export functions are callable', async () => { - expect(typeof focusDetect.isTerminalFocused).toBe('function'); - expect(typeof focusDetect.isFocusDetectionSupported).toBe('function'); - expect(typeof focusDetect.getTerminalName).toBe('function'); - expect(typeof focusDetect.getPlatform).toBe('function'); - expect(typeof focusDetect.clearFocusCache).toBe('function'); - expect(typeof focusDetect.resetTerminalDetection).toBe('function'); - expect(typeof focusDetect.getCacheState).toBe('function'); - }); - - test('default export functions work correctly', async () => { - const platform = focusDetect.getPlatform(); - expect(typeof platform).toBe('string'); - - const supported = focusDetect.isFocusDetectionSupported(); - expect(typeof supported.supported).toBe('boolean'); - - const result = await focusDetect.isTerminalFocused(); - expect(typeof result).toBe('boolean'); - }); - }); - - // ============================================================ - // ERROR HANDLING TESTS - // ============================================================ - - describe('error handling', () => { - test('isTerminalFocused handles errors gracefully (fail-open)', async () => { - // Even if something goes wrong internally, should not throw - const result = await isTerminalFocused(); - expect(typeof result).toBe('boolean'); - }); - - test('returns false on error (fail-open strategy)', async () => { - // The module is designed to return false on any error - // This ensures notifications still work even if focus detection fails - const platform = getPlatform(); - const supported = isFocusDetectionSupported(); - - if (!supported.supported) { - // Unsupported platforms should return false - const result = await isTerminalFocused(); - expect(result).toBe(false); - } - }); - - test('isFocusDetectionSupported never throws', () => { - // Should never throw - expect(() => isFocusDetectionSupported()).not.toThrow(); - }); - - test('getPlatform never throws', () => { - expect(() => getPlatform()).not.toThrow(); - }); - - test('clearFocusCache never throws', () => { - expect(() => clearFocusCache()).not.toThrow(); - }); - - test('resetTerminalDetection never throws', () => { - expect(() => resetTerminalDetection()).not.toThrow(); - }); - - test('getCacheState never throws', () => { - expect(() => getCacheState()).not.toThrow(); - }); - - test('getTerminalName never throws', () => { - expect(() => getTerminalName()).not.toThrow(); - }); - }); - - // ============================================================ - // INTEGRATION WITH CONFIG - // ============================================================ - - describe('integration with config', () => { - test('focus detection can be used with config settings', async () => { - // Simulate config settings - const config = { - suppressWhenFocused: true, - alwaysNotify: false - }; - - // If suppressWhenFocused is true and not alwaysNotify, check focus - if (config.suppressWhenFocused && !config.alwaysNotify) { - const focused = await isTerminalFocused(); - // Should suppress if focused is true - const shouldSuppress = focused; - expect(typeof shouldSuppress).toBe('boolean'); - } - }); - - test('alwaysNotify override works conceptually', async () => { - const config = { - suppressWhenFocused: true, - alwaysNotify: true - }; - - // When alwaysNotify is true, focus check should be skipped - if (config.alwaysNotify) { - // Don't suppress, regardless of focus - const shouldSuppress = false; - expect(shouldSuppress).toBe(false); - } - }); - - test('suppressWhenFocused=false skips focus check', async () => { - const config = { - suppressWhenFocused: false, - alwaysNotify: false - }; - - // When suppressWhenFocused is false, focus check should be skipped - if (!config.suppressWhenFocused) { - const shouldSuppress = false; - expect(shouldSuppress).toBe(false); - } - }); - }); -}); diff --git a/tests/unit/focus-detect.test.ts b/tests/unit/focus-detect.test.ts new file mode 100644 index 0000000..ec93e73 --- /dev/null +++ b/tests/unit/focus-detect.test.ts @@ -0,0 +1,1927 @@ +// @ts-nocheck +/** + * Unit Tests for Focus Detection Module + * + * Tests for the util/focus-detect.js module which provides terminal focus detection. + * Used to suppress notifications when the user is actively looking at the terminal. + * + * @see src/util/focus-detect.js + * @see docs/ARCHITECT_PLAN.md - Phase 3, Task 3.6 + */ + +import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import os from 'os'; +import { + createTestTempDir, + cleanupTestTempDir, + createTestLogsDir, + createMockShellRunner, + readTestFile, + testFileExists, + wait +} from '../setup.js'; + +// Import the focus detection module +import { + isTerminalFocused, + isFocusDetectionSupported, + getTerminalName, + getPlatform, + clearFocusCache, + resetTerminalDetection, + getCacheState, + KNOWN_TERMINALS_MACOS, + KNOWN_TERMINALS_WINDOWS +} from '../../src/util/focus-detect.js'; + +import focusDetect from '../../src/util/focus-detect.js'; + +const getEncodedPowerShellScriptFromCommand = (command) => { + const marker = '-EncodedCommand '; + const markerIndex = command.indexOf(marker); + if (markerIndex === -1) { + return null; + } + + return command.slice(markerIndex + marker.length).trim(); +}; + +const decodePowerShellScript = (encodedScript) => { + if (!encodedScript) { + return null; + } + + return Buffer.from(encodedScript, 'base64').toString('utf16le'); +}; + +describe('focus detection module', () => { + beforeEach(() => { + // Create test temp directory and reset caches before each test + createTestTempDir(); + clearFocusCache(); + resetTerminalDetection(); + }); + + afterEach(() => { + cleanupTestTempDir(); + clearFocusCache(); + resetTerminalDetection(); + }); + + // ============================================================ + // getPlatform() TESTS + // ============================================================ + + describe('getPlatform()', () => { + test('returns a string', () => { + const platform = getPlatform(); + expect(typeof platform).toBe('string'); + }); + + test('returns one of the known platforms', () => { + const platform = getPlatform(); + expect(['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'sunos', 'aix']).toContain(platform); + }); + + test('returns consistent value on repeated calls', () => { + const platform1 = getPlatform(); + const platform2 = getPlatform(); + expect(platform1).toBe(platform2); + }); + }); + + // ============================================================ + // isFocusDetectionSupported() TESTS + // ============================================================ + + describe('isFocusDetectionSupported()', () => { + test('returns an object', () => { + const result = isFocusDetectionSupported(); + expect(typeof result).toBe('object'); + }); + + test('returns object with supported property', () => { + const result = isFocusDetectionSupported(); + expect(result).toHaveProperty('supported'); + expect(typeof result.supported).toBe('boolean'); + }); + + test('returns reason when not supported', () => { + const result = isFocusDetectionSupported(); + // If not supported, should have a reason + if (!result.supported) { + expect(result).toHaveProperty('reason'); + expect(typeof result.reason).toBe('string'); + } + }); + + test('macOS should be supported', () => { + const platform = getPlatform(); + const result = isFocusDetectionSupported(); + + if (platform === 'darwin') { + expect(result.supported).toBe(true); + } + }); + + test('Windows should be supported', () => { + const platformSpy = spyOn(os, 'platform').mockReturnValue('win32'); + const result = isFocusDetectionSupported(); + expect(result.supported).toBe(true); + platformSpy.mockRestore(); + }); + + test('Linux support status is a boolean', () => { + const platform = getPlatform(); + const result = isFocusDetectionSupported(); + + if (platform === 'linux') { + expect(typeof result.supported).toBe('boolean'); + } + }); + }); + + // ============================================================ + // KNOWN_TERMINALS_MACOS TESTS + // ============================================================ + + describe('KNOWN_TERMINALS_MACOS', () => { + test('is an array', () => { + expect(Array.isArray(KNOWN_TERMINALS_MACOS)).toBe(true); + }); + + test('contains at least 10 terminal names', () => { + expect(KNOWN_TERMINALS_MACOS.length).toBeGreaterThanOrEqual(10); + }); + + test('includes Terminal (macOS default)', () => { + expect(KNOWN_TERMINALS_MACOS).toContain('Terminal'); + }); + + test('includes iTerm2', () => { + expect(KNOWN_TERMINALS_MACOS.some(t => t.includes('iTerm'))).toBe(true); + }); + + test('includes popular terminals like Alacritty, Hyper, Warp', () => { + expect(KNOWN_TERMINALS_MACOS).toContain('Alacritty'); + expect(KNOWN_TERMINALS_MACOS).toContain('Hyper'); + expect(KNOWN_TERMINALS_MACOS).toContain('Warp'); + }); + + test('all entries are non-empty strings', () => { + for (const terminal of KNOWN_TERMINALS_MACOS) { + expect(typeof terminal).toBe('string'); + expect(terminal.length).toBeGreaterThan(0); + } + }); + }); + + describe('KNOWN_TERMINALS_WINDOWS', () => { + test('is an array', () => { + expect(Array.isArray(KNOWN_TERMINALS_WINDOWS)).toBe(true); + }); + + test('includes Windows Terminal variants', () => { + expect(KNOWN_TERMINALS_WINDOWS).toContain('Windows Terminal'); + expect(KNOWN_TERMINALS_WINDOWS).toContain('WindowsTerminal'); + }); + + test('includes Windows shell process names', () => { + expect(KNOWN_TERMINALS_WINDOWS).toContain('PowerShell'); + expect(KNOWN_TERMINALS_WINDOWS).toContain('pwsh'); + expect(KNOWN_TERMINALS_WINDOWS).toContain('cmd.exe'); + expect(KNOWN_TERMINALS_WINDOWS).toContain('conhost'); + }); + + test('all entries are non-empty strings', () => { + for (const terminal of KNOWN_TERMINALS_WINDOWS) { + expect(typeof terminal).toBe('string'); + expect(terminal.length).toBeGreaterThan(0); + } + }); + }); + + // ============================================================ + // isTerminalFocused() - BASIC TESTS + // ============================================================ + + describe('isTerminalFocused() basic behavior', () => { + test('returns a Promise', () => { + const result = isTerminalFocused(); + expect(result).toBeInstanceOf(Promise); + }); + + test('Promise resolves to a boolean', async () => { + const result = await isTerminalFocused(); + expect(typeof result).toBe('boolean'); + }); + + test('accepts empty options object', async () => { + const result = await isTerminalFocused({}); + expect(typeof result).toBe('boolean'); + }); + + test('accepts options with debugLog', async () => { + createTestLogsDir(); + const result = await isTerminalFocused({ debugLog: true }); + expect(typeof result).toBe('boolean'); + }); + + test('handles null options gracefully', async () => { + // Should not throw with null + const result = await isTerminalFocused(null); + expect(typeof result).toBe('boolean'); + }); + + test('handles undefined options gracefully', async () => { + const result = await isTerminalFocused(undefined); + expect(typeof result).toBe('boolean'); + }); + }); + + // ============================================================ + // isTerminalFocused() - PLATFORM-SPECIFIC BEHAVIOR + // ============================================================ + + describe('isTerminalFocused() platform behavior', () => { + test('returns false on unsupported platforms (fail-open)', async () => { + const supported = isFocusDetectionSupported(); + + if (!supported.supported) { + // On unsupported platforms, should return false (fail-open: still notify) + const result = await isTerminalFocused(); + expect(result).toBe(false); + } + }); + + test('returns boolean on Windows', async () => { + const platform = getPlatform(); + + if (platform === 'win32') { + clearFocusCache(); + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('explorer\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(typeof result).toBe('boolean'); + } + }); + + test('returns boolean on Linux', async () => { + const platform = getPlatform(); + + if (platform === 'linux') { + const result = await isTerminalFocused(); + expect(typeof result).toBe('boolean'); + } + }); + + test('handles macOS check without throwing', async () => { + const platform = getPlatform(); + + if (platform === 'darwin') { + // Should not throw - may return true or false depending on focused app + await expect(isTerminalFocused()).resolves.toBeDefined(); + } + }); + }); + + // ============================================================ + // WINDOWS FOCUS DETECTION TESTS (MOCKED POWERSHELL) + // ============================================================ + + describe('Windows focus detection', () => { + let platformSpy; + + beforeEach(() => { + platformSpy = spyOn(os, 'platform').mockReturnValue('win32'); + clearFocusCache(); + }); + + afterEach(() => { + if (platformSpy) { + platformSpy.mockRestore(); + } + clearFocusCache(); + }); + + test('detects Windows Terminal process as focused terminal', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('WindowsTerminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + + const command = shellRunner.getLastCall().command; + expect(command).toContain('powershell'); + expect(command).toContain('-EncodedCommand'); + + const encodedScript = getEncodedPowerShellScriptFromCommand(command); + const decodedScript = decodePowerShellScript(encodedScript); + expect(decodedScript).toContain('Get-Process -Id $processId'); + }); + + test('detects PowerShell and cmd.exe process names', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('powershell\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const powershellResult = await isTerminalFocused({ shellRunner }); + expect(powershellResult).toBe(true); + + clearFocusCache(); + + const shellRunnerCmd = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('cmd.exe\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const cmdResult = await isTerminalFocused({ shellRunner: shellRunnerCmd }); + expect(cmdResult).toBe(true); + }); + + test('returns false for non-terminal process names', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('explorer\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('returns false when PowerShell execution fails (fail-open)', async () => { + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('PowerShell execution failed'); + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('writes PowerShell failure debug output to file', async () => { + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('PowerShell not found'); + }, + }); + + const result = await isTerminalFocused({ debugLog: true, shellRunner }); + expect(result).toBe(false); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + expect(logContent).toContain('Failed to get frontmost Windows process'); + expect(logContent).toContain('PowerShell not found'); + }); + + // ---------------------------------------------------------- + // All KNOWN_TERMINALS_WINDOWS detection + // ---------------------------------------------------------- + + test('detects all known Windows terminal process names as focused', async () => { + for (const terminal of KNOWN_TERMINALS_WINDOWS) { + clearFocusCache(); + + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(`${terminal}\n`), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + } + }); + + // ---------------------------------------------------------- + // Empty / invalid output scenarios + // ---------------------------------------------------------- + + test('returns false when PowerShell output is empty string', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('returns false when PowerShell output is only whitespace or newlines', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(' \n \r\n '), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('returns false for invalid/binary output from PowerShell', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('\x00\x01\x02\x03'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + // ---------------------------------------------------------- + // Non-terminal app detection + // ---------------------------------------------------------- + + test('returns false for non-terminal apps (explorer, chrome, firefox, notepad, Teams)', async () => { + const nonTerminalApps = ['explorer', 'chrome', 'firefox', 'notepad', 'Teams', 'Spotify', 'slack']; + + for (const app of nonTerminalApps) { + clearFocusCache(); + + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(`${app}\n`), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + } + }); + + // ---------------------------------------------------------- + // PowerShell command structure verification + // ---------------------------------------------------------- + + test('PowerShell command includes required flags (-NoProfile, -NonInteractive, -ExecutionPolicy Bypass)', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('WindowsTerminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const command = shellRunner.getLastCall().command; + expect(command).toContain('-NoProfile'); + expect(command).toContain('-NonInteractive'); + expect(command).toContain('-ExecutionPolicy Bypass'); + }); + + test('decoded PowerShell script includes Win32 API interop (user32.dll, GetForegroundWindow)', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('WindowsTerminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const command = shellRunner.getLastCall().command; + const encodedScript = getEncodedPowerShellScriptFromCommand(command); + const decodedScript = decodePowerShellScript(encodedScript); + + expect(decodedScript).toContain('GetForegroundWindow'); + expect(decodedScript).toContain('GetWindowThreadProcessId'); + expect(decodedScript).toContain('user32.dll'); + }); + + test('decoded PowerShell script includes IsIconic and IsWindowVisible checks for minimized/hidden windows', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('WindowsTerminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const command = shellRunner.getLastCall().command; + const encodedScript = getEncodedPowerShellScriptFromCommand(command); + const decodedScript = decodePowerShellScript(encodedScript); + + expect(decodedScript).toContain('IsIconic'); + expect(decodedScript).toContain('IsWindowVisible'); + expect(decodedScript).toContain('minimized'); + expect(decodedScript).toContain('visible'); + }); + + // ---------------------------------------------------------- + // Minimized/Hidden window detection tests + // ---------------------------------------------------------- + + test('returns false when PowerShell returns empty output (simulating minimized window - IsIconic returns true)', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + // Empty output means PowerShell script returned early due to IsIconic check + expect(result).toBe(false); + }); + + test('returns false when PowerShell returns empty output (simulating hidden window - IsWindowVisible returns false)', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + // Empty output means PowerShell script returned early due to IsWindowVisible check + expect(result).toBe(false); + }); + + test('minimized terminal window should NOT be detected as focused (empty PowerShell output)', async () => { + // When a terminal is minimized, the PowerShell script will: + // 1. Get the foreground window handle + // 2. Check IsIconic() which returns true for minimized windows + // 3. Return early without outputting a process name + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + + const cache = getCacheState(); + expect(cache.isFocused).toBe(false); + }); + + test('hidden/invisible window should NOT be detected as focused (empty PowerShell output)', async () => { + // When a window is hidden/invisible, the PowerShell script will: + // 1. Get the foreground window handle + // 2. Check IsWindowVisible() which returns false + // 3. Return early without outputting a process name + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + + const cache = getCacheState(); + expect(cache.isFocused).toBe(false); + }); + + test('normal visible window returns process name correctly', async () => { + // Normal case: window is visible and not minimized + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('WindowsTerminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + + const cache = getCacheState(); + expect(cache.isFocused).toBe(true); + expect(cache.terminalName).toBe('WindowsTerminal'); + }); + + test('PowerShell script includes early return for zero foreground window (showing desktop)', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const command = shellRunner.getLastCall().command; + const encodedScript = getEncodedPowerShellScriptFromCommand(command); + const decodedScript = decodePowerShellScript(encodedScript); + + expect(decodedScript).toContain('No foreground window'); + }); + + // ---------------------------------------------------------- + // stderr handling + // ---------------------------------------------------------- + + test('handles PowerShell stderr output without failing detection', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('WindowsTerminal\n'), + stderr: Buffer.from('WARNING: Some deprecation notice\n'), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + // ---------------------------------------------------------- + // Cache interaction on Windows path + // ---------------------------------------------------------- + + test('caches Windows focus result and only calls shell once within TTL', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('WindowsTerminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + await isTerminalFocused({ shellRunner }); + await isTerminalFocused({ shellRunner }); + + // Only the first call should hit the shell; subsequent calls use cache + expect(shellRunner.getCallCount()).toBe(1); + }); + + test('updates focus cache state after Windows detection', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('WindowsTerminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const cache = getCacheState(); + expect(cache.isFocused).toBe(true); + expect(cache.timestamp).toBeGreaterThan(0); + expect(cache.terminalName).toBe('WindowsTerminal'); + }); + + test('sets cache to not-focused when non-terminal app is detected', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('chrome\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const cache = getCacheState(); + expect(cache.isFocused).toBe(false); + expect(cache.terminalName).toBe('chrome'); + }); + + test('sets cache terminalName to null on PowerShell failure', async () => { + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('command not found'); + }, + }); + + await isTerminalFocused({ shellRunner }); + + const cache = getCacheState(); + expect(cache.isFocused).toBe(false); + expect(cache.terminalName).toBeNull(); + }); + + // ---------------------------------------------------------- + // Debug logging on Windows path + // ---------------------------------------------------------- + + test('logs debug info when PowerShell returns empty output', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ debugLog: true, shellRunner }); + expect(result).toBe(false); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + expect(logContent).toContain('empty focused process name'); + }); + + test('logs PowerShell stderr content when debug is enabled', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('WindowsTerminal\n'), + stderr: Buffer.from('Some warning\n'), + exitCode: 0, + }), + }); + + await isTerminalFocused({ debugLog: true, shellRunner }); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + expect(logContent).toContain('PowerShell stderr'); + expect(logContent).toContain('Some warning'); + }); + + test('does not create log file when debugLog is false on Windows', async () => { + createTestTempDir(); + + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('should not be logged'); + }, + }); + + await isTerminalFocused({ debugLog: false, shellRunner }); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + expect(logContent).toBeNull(); + }); + + // ---------------------------------------------------------- + // Platform mocking verification + // ---------------------------------------------------------- + + test('platform spy correctly returns win32', () => { + expect(getPlatform()).toBe('win32'); + }); + + test('isFocusDetectionSupported returns true when mocked as win32', () => { + const result = isFocusDetectionSupported(); + expect(result.supported).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + test('shell runner receives exactly one call per uncached focus check', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('cmd\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + expect(shellRunner.getCallCount()).toBe(1); + expect(shellRunner.wasCalledWith('powershell')).toBe(true); + }); + }); + + // ============================================================ + // macOS FOCUS DETECTION TESTS (MOCKED APPLESCRIPT) + // ============================================================ + + describe('macOS focus detection (mocked)', () => { + let platformSpy; + + beforeEach(() => { + platformSpy = spyOn(os, 'platform').mockReturnValue('darwin'); + clearFocusCache(); + resetTerminalDetection(); + }); + + afterEach(() => { + if (platformSpy) { + platformSpy.mockRestore(); + } + clearFocusCache(); + resetTerminalDetection(); + }); + + // ---------------------------------------------------------- + // Happy path: known terminal apps detected as focused + // ---------------------------------------------------------- + + test('detects Terminal.app as focused terminal', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Terminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('detects iTerm2 as focused terminal', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('iTerm2\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('detects all KNOWN_TERMINALS_MACOS entries as focused', async () => { + for (const terminal of KNOWN_TERMINALS_MACOS) { + clearFocusCache(); + + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(`${terminal}\n`), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + } + }); + + // ---------------------------------------------------------- + // Non-terminal apps should NOT be detected as focused + // ---------------------------------------------------------- + + test('returns false when Safari is frontmost', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Safari\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('returns false when Finder is frontmost', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Finder\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('returns false for other non-terminal macOS apps', async () => { + const nonTerminalApps = ['Mail', 'Preview', 'Notes', 'Slack', 'Spotify', 'Chrome', 'Firefox']; + + for (const app of nonTerminalApps) { + clearFocusCache(); + + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(`${app}\n`), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + } + }); + + // ---------------------------------------------------------- + // Error scenarios: fail-open behavior + // ---------------------------------------------------------- + + test('returns false when AppleScript execution fails (fail-open)', async () => { + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('osascript: command not found'); + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('returns false when osascript returns empty output', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('returns false when osascript returns whitespace-only output', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(' \n\t\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('returns false when osascript returns only newlines', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('\n\n\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('returns false when osascript throws timeout error', async () => { + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('Command timed out after 2000ms'); + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + // ---------------------------------------------------------- + // AppleScript command verification + // ---------------------------------------------------------- + + test('sends osascript command to shell runner', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Terminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + expect(shellRunner.getCallCount()).toBe(1); + const command = shellRunner.getLastCall().command; + expect(command).toContain('osascript'); + }); + + test('AppleScript command contains System Events tell block', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Terminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const command = shellRunner.getLastCall().command; + expect(command).toContain('System Events'); + expect(command).toContain('frontmost'); + }); + + test('AppleScript retrieves frontmost application process name', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Terminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const command = shellRunner.getLastCall().command; + expect(command).toContain('first application process whose frontmost is true'); + expect(command).toContain('name of frontApp'); + }); + + test('osascript command uses -e flag for inline script', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Terminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const command = shellRunner.getLastCall().command; + expect(command).toContain("osascript -e '"); + }); + + // ---------------------------------------------------------- + // Minimized/Hidden window detection tests + // ---------------------------------------------------------- + + test('AppleScript includes visibility check for hidden apps (Cmd+H)', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Terminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const command = shellRunner.getLastCall().command; + expect(command).toContain('visible of frontApp'); + expect(command).toContain('is false'); + }); + + test('AppleScript includes miniaturized check for minimized windows', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Terminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const command = shellRunner.getLastCall().command; + expect(command).toContain('miniaturized is false'); + expect(command).toContain('visible is true'); + }); + + test('AppleScript checks for visible, non-minimized windows', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Terminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const command = shellRunner.getLastCall().command; + expect(command).toContain('every window of frontApp whose visible is true and miniaturized is false'); + expect(command).toContain('count of windowList'); + }); + + test('minimized terminal window should NOT be detected as focused (empty AppleScript output)', async () => { + // When a terminal is minimized, the AppleScript will: + // 1. Check if app has visible, non-minimized windows + // 2. Find no windows matching the criteria + // 3. Return empty string + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + + const cache = getCacheState(); + expect(cache.isFocused).toBe(false); + }); + + test('hidden app (Cmd+H) should NOT be detected as focused (empty AppleScript output)', async () => { + // When an app is hidden with Cmd+H, the AppleScript will: + // 1. Check visible of frontApp + // 2. Find that visible is false + // 3. Return empty string + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + + const cache = getCacheState(); + expect(cache.isFocused).toBe(false); + }); + + test('normal visible terminal window returns app name correctly', async () => { + // Normal case: terminal is visible and has non-minimized windows + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Terminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + + const cache = getCacheState(); + expect(cache.isFocused).toBe(true); + expect(cache.terminalName).toBe('Terminal'); + }); + + test('AppleScript returns empty string for app with no visible windows', async () => { + // Simulates an app that is frontmost but has no visible windows + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + // ---------------------------------------------------------- + // Debug logging on macOS errors + // ---------------------------------------------------------- + + test('writes AppleScript failure debug output to log file', async () => { + createTestLogsDir(); + + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('osascript permission denied'); + }, + }); + + const result = await isTerminalFocused({ debugLog: true, shellRunner }); + expect(result).toBe(false); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + expect(logContent).toContain('Failed to get frontmost app'); + expect(logContent).toContain('osascript permission denied'); + }); + + test('logs frontmost app name when debug is enabled', async () => { + createTestLogsDir(); + + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Safari\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ debugLog: true, shellRunner }); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + expect(logContent).toContain('[focus-detect]'); + expect(logContent).toContain('Safari'); + }); + + test('logs that non-terminal app is NOT a known terminal', async () => { + createTestLogsDir(); + + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Preview\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ debugLog: true, shellRunner }); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + expect(logContent).toContain('NOT a known terminal'); + }); + + test('does not create log file when debugLog is false', async () => { + createTestTempDir(); + + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('should not be logged'); + }, + }); + + await isTerminalFocused({ debugLog: false, shellRunner }); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + expect(logContent).toBeNull(); + }); + + // ---------------------------------------------------------- + // Cache interaction on macOS path + // ---------------------------------------------------------- + + test('updates cache after successful macOS focus check', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Terminal\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + + const cache = getCacheState(); + expect(cache.isFocused).toBe(true); + expect(cache.timestamp).toBeGreaterThan(0); + expect(cache.terminalName).toBe('Terminal'); + }); + + test('updates cache with null terminal on macOS error', async () => { + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('osascript error'); + }, + }); + + await isTerminalFocused({ shellRunner }); + + const cache = getCacheState(); + expect(cache.isFocused).toBe(false); + expect(cache.timestamp).toBeGreaterThan(0); + expect(cache.terminalName).toBeNull(); + }); + + test('second call within TTL uses cached value, no extra shell call', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('iTerm2\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result1 = await isTerminalFocused({ shellRunner }); + expect(result1).toBe(true); + expect(shellRunner.getCallCount()).toBe(1); + + // Second call should hit cache + const result2 = await isTerminalFocused({ shellRunner }); + expect(result2).toBe(true); + // No additional shell call - still 1 + expect(shellRunner.getCallCount()).toBe(1); + }); + + // ---------------------------------------------------------- + // Platform mocking verification + // ---------------------------------------------------------- + + test('platform spy correctly returns darwin', () => { + expect(getPlatform()).toBe('darwin'); + }); + + test('isFocusDetectionSupported returns true when mocked as darwin', () => { + const result = isFocusDetectionSupported(); + expect(result.supported).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + test('shell runner receives exactly one call per uncached focus check', async () => { + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('Alacritty\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + await isTerminalFocused({ shellRunner }); + expect(shellRunner.getCallCount()).toBe(1); + expect(shellRunner.wasCalledWith('osascript')).toBe(true); + }); + }); + + // ============================================================ + // CACHING BEHAVIOR TESTS + // ============================================================ + + describe('focus detection caching', () => { + test('getCacheState() returns cache object', () => { + const cache = getCacheState(); + expect(typeof cache).toBe('object'); + expect(cache).toHaveProperty('isFocused'); + expect(cache).toHaveProperty('timestamp'); + expect(cache).toHaveProperty('terminalName'); + }); + + test('cache starts with default values', () => { + clearFocusCache(); + const cache = getCacheState(); + expect(cache.isFocused).toBe(false); + expect(cache.timestamp).toBe(0); + expect(cache.terminalName).toBeNull(); + }); + + test('cache is updated after isTerminalFocused() call', async () => { + clearFocusCache(); + const cacheBefore = getCacheState(); + expect(cacheBefore.timestamp).toBe(0); + + await isTerminalFocused(); + + const cacheAfter = getCacheState(); + expect(cacheAfter.timestamp).toBeGreaterThan(0); + }); + + test('clearFocusCache() resets cache state', async () => { + // First make a call to populate cache + await isTerminalFocused(); + + const cachePopulated = getCacheState(); + expect(cachePopulated.timestamp).toBeGreaterThan(0); + + // Clear cache + clearFocusCache(); + + const cacheCleared = getCacheState(); + expect(cacheCleared.timestamp).toBe(0); + expect(cacheCleared.isFocused).toBe(false); + }); + + test('caching prevents multiple system calls within TTL', async () => { + clearFocusCache(); + + // First call populates cache + const start = Date.now(); + const result1 = await isTerminalFocused(); + const cache1 = getCacheState(); + + // Second call should use cache (no new timestamp) + const result2 = await isTerminalFocused(); + const cache2 = getCacheState(); + + // Third call should also use cache + const result3 = await isTerminalFocused(); + const cache3 = getCacheState(); + + // All results should be the same (from cache) + expect(result1).toBe(result2); + expect(result2).toBe(result3); + + // Timestamps should be the same (cache hit) + expect(cache2.timestamp).toBe(cache1.timestamp); + expect(cache3.timestamp).toBe(cache1.timestamp); + }); + + test('cache expires after TTL (500ms)', async () => { + clearFocusCache(); + + // First call populates cache + await isTerminalFocused(); + const cache1 = getCacheState(); + const timestamp1 = cache1.timestamp; + + // Wait for cache to expire (TTL is 500ms, wait 600ms to be safe) + await wait(600); + + // Next call should refresh cache + await isTerminalFocused(); + const cache2 = getCacheState(); + const timestamp2 = cache2.timestamp; + + // Timestamps should be different (cache miss, new system call) + expect(timestamp2).toBeGreaterThan(timestamp1); + expect(timestamp2 - timestamp1).toBeGreaterThanOrEqual(500); + }); + + test('multiple rapid calls use cached value', async () => { + clearFocusCache(); + + // Make 5 rapid calls + const results = await Promise.all([ + isTerminalFocused(), + isTerminalFocused(), + isTerminalFocused(), + isTerminalFocused(), + isTerminalFocused() + ]); + + // All should return the same value + const firstResult = results[0]; + for (const result of results) { + expect(result).toBe(firstResult); + } + }); + }); + + // ============================================================ + // TERMINAL DETECTION TESTS + // ============================================================ + + describe('getTerminalName()', () => { + test('returns string or null', () => { + const result = getTerminalName(); + expect(result === null || typeof result === 'string').toBe(true); + }); + + test('caches terminal detection result', () => { + resetTerminalDetection(); + + const result1 = getTerminalName(); + const result2 = getTerminalName(); + + // Should return the same value (cached) + expect(result1).toBe(result2); + }); + + test('accepts debug parameter', () => { + resetTerminalDetection(); + createTestLogsDir(); + + // Should not throw + const result = getTerminalName(true); + expect(result === null || typeof result === 'string').toBe(true); + }); + + test('resetTerminalDetection() clears cached value', () => { + // Populate cache + getTerminalName(); + + // Reset + resetTerminalDetection(); + + // Should work again without errors + const result = getTerminalName(); + expect(result === null || typeof result === 'string').toBe(true); + }); + }); + + // ============================================================ + // DEBUG LOGGING TESTS + // ============================================================ + + describe('debug logging', () => { + test('creates logs directory when debugLog is true', async () => { + // Ensure temp dir exists + createTestTempDir(); + + await isTerminalFocused({ debugLog: true }); + + // Check if logs directory was created + expect(testFileExists('logs')).toBe(true); + }); + + test('writes to debug log file when enabled', async () => { + createTestTempDir(); + + await isTerminalFocused({ debugLog: true }); + + // Check if log file exists + expect(testFileExists('logs/smart-voice-notify-debug.log')).toBe(true); + }); + + test('debug log contains focus detection entries', async () => { + createTestTempDir(); + + await isTerminalFocused({ debugLog: true }); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + if (logContent) { + expect(logContent).toContain('[focus-detect]'); + } + }); + + test('debug logging does not affect return value', async () => { + clearFocusCache(); + createTestTempDir(); + + const withDebug = await isTerminalFocused({ debugLog: true }); + + clearFocusCache(); + + const withoutDebug = await isTerminalFocused({ debugLog: false }); + + // Both should be the same type + expect(typeof withDebug).toBe('boolean'); + expect(typeof withoutDebug).toBe('boolean'); + }); + + test('no log file created when debugLog is false', async () => { + createTestTempDir(); + + await isTerminalFocused({ debugLog: false }); + + // Log file should not exist (directory might not even be created) + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + // Either no log or only contains entries from debug=true calls + expect(logContent === null || !logContent.includes('[focus-detect]') || logContent.includes('[focus-detect]')).toBe(true); + }); + }); + + // ============================================================ + // DEFAULT EXPORT TESTS + // ============================================================ + + describe('default export', () => { + test('exports all expected functions', () => { + expect(focusDetect).toHaveProperty('isTerminalFocused'); + expect(focusDetect).toHaveProperty('isFocusDetectionSupported'); + expect(focusDetect).toHaveProperty('getTerminalName'); + expect(focusDetect).toHaveProperty('getPlatform'); + expect(focusDetect).toHaveProperty('clearFocusCache'); + expect(focusDetect).toHaveProperty('resetTerminalDetection'); + expect(focusDetect).toHaveProperty('getCacheState'); + expect(focusDetect).toHaveProperty('KNOWN_TERMINALS_MACOS'); + expect(focusDetect).toHaveProperty('KNOWN_TERMINALS_WINDOWS'); + }); + + test('default export functions are callable', async () => { + expect(typeof focusDetect.isTerminalFocused).toBe('function'); + expect(typeof focusDetect.isFocusDetectionSupported).toBe('function'); + expect(typeof focusDetect.getTerminalName).toBe('function'); + expect(typeof focusDetect.getPlatform).toBe('function'); + expect(typeof focusDetect.clearFocusCache).toBe('function'); + expect(typeof focusDetect.resetTerminalDetection).toBe('function'); + expect(typeof focusDetect.getCacheState).toBe('function'); + }); + + test('default export functions work correctly', async () => { + const platform = focusDetect.getPlatform(); + expect(typeof platform).toBe('string'); + + const supported = focusDetect.isFocusDetectionSupported(); + expect(typeof supported.supported).toBe('boolean'); + + const result = await focusDetect.isTerminalFocused(); + expect(typeof result).toBe('boolean'); + }); + }); + + // ============================================================ + // ERROR HANDLING TESTS + // ============================================================ + + describe('error handling', () => { + test('isTerminalFocused handles errors gracefully (fail-open)', async () => { + // Even if something goes wrong internally, should not throw + const result = await isTerminalFocused(); + expect(typeof result).toBe('boolean'); + }); + + test('returns false on error (fail-open strategy)', async () => { + // The module is designed to return false on any error + // This ensures notifications still work even if focus detection fails + const platform = getPlatform(); + const supported = isFocusDetectionSupported(); + + if (!supported.supported) { + // Unsupported platforms should return false + const result = await isTerminalFocused(); + expect(result).toBe(false); + } + }); + + test('isFocusDetectionSupported never throws', () => { + // Should never throw + expect(() => isFocusDetectionSupported()).not.toThrow(); + }); + + test('getPlatform never throws', () => { + expect(() => getPlatform()).not.toThrow(); + }); + + test('clearFocusCache never throws', () => { + expect(() => clearFocusCache()).not.toThrow(); + }); + + test('resetTerminalDetection never throws', () => { + expect(() => resetTerminalDetection()).not.toThrow(); + }); + + test('getCacheState never throws', () => { + expect(() => getCacheState()).not.toThrow(); + }); + + test('getTerminalName never throws', () => { + expect(() => getTerminalName()).not.toThrow(); + }); + }); + + // ============================================================ + // INTEGRATION WITH CONFIG + // ============================================================ + + describe('integration with config', () => { + test('focus detection can be used with config settings', async () => { + // Simulate config settings + const config = { + suppressWhenFocused: true, + alwaysNotify: false + }; + + // If suppressWhenFocused is true and not alwaysNotify, check focus + if (config.suppressWhenFocused && !config.alwaysNotify) { + const focused = await isTerminalFocused(); + // Should suppress if focused is true + const shouldSuppress = focused; + expect(typeof shouldSuppress).toBe('boolean'); + } + }); + + test('alwaysNotify override works conceptually', async () => { + const config = { + suppressWhenFocused: true, + alwaysNotify: true + }; + + // When alwaysNotify is true, focus check should be skipped + if (config.alwaysNotify) { + // Don't suppress, regardless of focus + const shouldSuppress = false; + expect(shouldSuppress).toBe(false); + } + }); + + test('suppressWhenFocused=false skips focus check', async () => { + const config = { + suppressWhenFocused: false, + alwaysNotify: false + }; + + // When suppressWhenFocused is false, focus check should be skipped + if (!config.suppressWhenFocused) { + const shouldSuppress = false; + expect(shouldSuppress).toBe(false); + } + }); + }); + + // ============================================================ + // CONFIG-BASED FOCUS DETECTION SUPPRESSION TESTS + // Tests for shouldSuppressNotification() logic from src/index.ts + // ============================================================ + + describe('config-based focus detection suppression', () => { + // Simulates the shouldSuppressNotification() logic from src/index.ts + // This allows us to test the config combinations without importing the full plugin + const simulateShouldSuppress = async ( + config: { suppressWhenFocused: boolean; alwaysNotify: boolean }, + focusCheckFn: () => Promise + ): Promise<{ shouldSuppress: boolean; focusCheckCalled: boolean }> => { + let focusCheckCalled = false; + + // If alwaysNotify is true, never suppress (and don't check focus) + if (config.alwaysNotify) { + return { shouldSuppress: false, focusCheckCalled: false }; + } + + // If suppressWhenFocused is disabled, don't suppress (and don't check focus) + if (!config.suppressWhenFocused) { + return { shouldSuppress: false, focusCheckCalled: false }; + } + + // Check if terminal is focused + focusCheckCalled = true; + try { + const isFocused = await focusCheckFn(); + if (isFocused) { + return { shouldSuppress: true, focusCheckCalled: true }; + } + } catch { + // On error, fail open (don't suppress) + } + + return { shouldSuppress: false, focusCheckCalled: true }; + }; + + // ============================================================ + // TEST: Focus detection NOT called when suppressWhenFocused=false + // ============================================================ + test('focus detection is NOT called when suppressWhenFocused=false', async () => { + const config = { + suppressWhenFocused: false, + alwaysNotify: false + }; + + let focusCheckCallCount = 0; + const mockFocusCheck = async () => { + focusCheckCallCount++; + return true; // Terminal is "focused" + }; + + const result = await simulateShouldSuppress(config, mockFocusCheck); + + // Should NOT suppress (regardless of terminal focus) + expect(result.shouldSuppress).toBe(false); + // Focus check should NOT have been called + expect(result.focusCheckCalled).toBe(false); + expect(focusCheckCallCount).toBe(0); + }); + + // ============================================================ + // TEST: Focus detection NOT called when alwaysNotify=true + // ============================================================ + test('focus detection is NOT called when alwaysNotify=true', async () => { + const config = { + suppressWhenFocused: true, // Even with this enabled + alwaysNotify: true + }; + + let focusCheckCallCount = 0; + const mockFocusCheck = async () => { + focusCheckCallCount++; + return true; // Terminal is "focused" + }; + + const result = await simulateShouldSuppress(config, mockFocusCheck); + + // Should NOT suppress (alwaysNotify overrides everything) + expect(result.shouldSuppress).toBe(false); + // Focus check should NOT have been called + expect(result.focusCheckCalled).toBe(false); + expect(focusCheckCallCount).toBe(0); + }); + + // ============================================================ + // TEST: Focus detection IS called when suppressWhenFocused=true AND alwaysNotify=false + // ============================================================ + test('focus detection IS called when suppressWhenFocused=true AND alwaysNotify=false', async () => { + const config = { + suppressWhenFocused: true, + alwaysNotify: false + }; + + let focusCheckCallCount = 0; + const mockFocusCheck = async () => { + focusCheckCallCount++; + return false; // Terminal is NOT focused + }; + + const result = await simulateShouldSuppress(config, mockFocusCheck); + + // Focus check SHOULD have been called + expect(result.focusCheckCalled).toBe(true); + expect(focusCheckCallCount).toBe(1); + // Should NOT suppress (terminal not focused) + expect(result.shouldSuppress).toBe(false); + }); + + // ============================================================ + // TEST: Notifications suppressed when terminal focused AND suppressWhenFocused=true + // ============================================================ + test('notifications are suppressed when terminal is focused AND suppressWhenFocused=true', async () => { + const config = { + suppressWhenFocused: true, + alwaysNotify: false + }; + + const mockFocusCheck = async () => true; // Terminal IS focused + + const result = await simulateShouldSuppress(config, mockFocusCheck); + + // Focus check SHOULD have been called + expect(result.focusCheckCalled).toBe(true); + // SHOULD suppress (terminal is focused) + expect(result.shouldSuppress).toBe(true); + }); + + // ============================================================ + // TEST: Notifications NOT suppressed when terminal focused BUT suppressWhenFocused=false + // ============================================================ + test('notifications are NOT suppressed when terminal is focused BUT suppressWhenFocused=false', async () => { + const config = { + suppressWhenFocused: false, + alwaysNotify: false + }; + + let focusCheckCallCount = 0; + const mockFocusCheck = async () => { + focusCheckCallCount++; + return true; // Terminal IS focused + }; + + const result = await simulateShouldSuppress(config, mockFocusCheck); + + // Focus check should NOT have been called + expect(result.focusCheckCalled).toBe(false); + expect(focusCheckCallCount).toBe(0); + // Should NOT suppress (suppressWhenFocused is disabled) + expect(result.shouldSuppress).toBe(false); + }); + + // ============================================================ + // TEST: alwaysNotify=true overrides suppressWhenFocused=true even when focused + // ============================================================ + test('alwaysNotify=true overrides suppressWhenFocused=true even when terminal is focused', async () => { + const config = { + suppressWhenFocused: true, + alwaysNotify: true + }; + + let focusCheckCallCount = 0; + const mockFocusCheck = async () => { + focusCheckCallCount++; + return true; // Terminal IS focused + }; + + const result = await simulateShouldSuppress(config, mockFocusCheck); + + // Focus check should NOT have been called + expect(result.focusCheckCalled).toBe(false); + expect(focusCheckCallCount).toBe(0); + // Should NOT suppress (alwaysNotify takes precedence) + expect(result.shouldSuppress).toBe(false); + }); + + // ============================================================ + // TEST: Error handling - fail-open when focus check throws + // ============================================================ + test('fails open (does not suppress) when focus check throws error', async () => { + const config = { + suppressWhenFocused: true, + alwaysNotify: false + }; + + const mockFocusCheck = async () => { + throw new Error('Focus detection failed'); + }; + + const result = await simulateShouldSuppress(config, mockFocusCheck); + + // Focus check SHOULD have been called + expect(result.focusCheckCalled).toBe(true); + // Should NOT suppress (fail-open on error) + expect(result.shouldSuppress).toBe(false); + }); + + // ============================================================ + // TEST: Default config values (suppressWhenFocused=false, alwaysNotify=false) + // ============================================================ + test('default config (suppressWhenFocused=false) does not check focus', async () => { + // These are the actual defaults from src/util/config.ts + const config = { + suppressWhenFocused: false, + alwaysNotify: false + }; + + let focusCheckCallCount = 0; + const mockFocusCheck = async () => { + focusCheckCallCount++; + return true; + }; + + const result = await simulateShouldSuppress(config, mockFocusCheck); + + // With defaults, focus check should never be called + expect(result.focusCheckCalled).toBe(false); + expect(focusCheckCallCount).toBe(0); + expect(result.shouldSuppress).toBe(false); + }); + }); +}); diff --git a/tests/unit/linux-focus-detect.test.ts b/tests/unit/linux-focus-detect.test.ts new file mode 100644 index 0000000..5cc9761 --- /dev/null +++ b/tests/unit/linux-focus-detect.test.ts @@ -0,0 +1,1359 @@ +// @ts-nocheck +/** + * Linux Platform Focus Detection Tests (Mocked) + * + * Tests Linux-specific focus detection (X11 and Wayland) with full mocking. + * These tests run on ALL platforms (Windows, macOS, Linux) via platform mocking. + * + * Covers: + * - X11 focus detection via xdotool and xprop + * - Wayland focus detection via swaymsg (Sway), gdbus (GNOME), qdbus (KDE) + * - Session type routing (x11, wayland, tty, unknown) + * - Desktop environment detection + * - Error handling and fail-open behavior + * - Debug logging to file on errors + * - Cache behavior under Linux mocking + * - Known terminal matching for all KNOWN_TERMINALS_LINUX entries + * + * @see src/util/focus-detect.ts + */ + +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import os from 'os'; +import { + isTerminalFocused, + clearFocusCache, + resetTerminalDetection, + getCacheState, + KNOWN_TERMINALS_LINUX, +} from '../../src/util/focus-detect.js'; +import { + createTestTempDir, + cleanupTestTempDir, + createTestLogsDir, + createMockShellRunner, + readTestFile, +} from '../setup.js'; + +// ================================================================ +// HELPERS +// ================================================================ + +/** Build a minimal Sway tree JSON with one focused node. */ +const buildSwayTree = (focusedNode) => ({ + nodes: [ + { + nodes: [focusedNode], + floating_nodes: [], + }, + ], + floating_nodes: [], +}); + +/** Build a Sway tree with a focused node in floating_nodes. */ +const buildSwayTreeFloating = (focusedNode) => ({ + nodes: [ + { + nodes: [], + floating_nodes: [focusedNode], + }, + ], + floating_nodes: [], +}); + +/** Create a handler that routes xdotool class to a given app name. */ +const xdotoolClassHandler = (appName) => (cmd) => { + if (cmd.includes('xdotool getwindowfocus getwindowclassname')) { + return { stdout: Buffer.from(`${appName}\n`), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; +}; + +// ================================================================ +// LINUX FOCUS DETECTION TESTS +// ================================================================ + +describe('Linux focus detection (mocked)', () => { + let platformSpy; + let savedEnv; + + beforeEach(() => { + platformSpy = spyOn(os, 'platform').mockReturnValue('linux'); + + savedEnv = { ...process.env }; + delete process.env.XDG_SESSION_TYPE; + delete process.env.WAYLAND_DISPLAY; + delete process.env.DISPLAY; + delete process.env.XDG_CURRENT_DESKTOP; + delete process.env.XDG_SESSION_DESKTOP; + delete process.env.DESKTOP_SESSION; + delete process.env.SWAYSOCK; + + clearFocusCache(); + resetTerminalDetection(); + createTestTempDir(); + }); + + afterEach(() => { + platformSpy.mockRestore(); + process.env = savedEnv; + clearFocusCache(); + resetTerminalDetection(); + cleanupTestTempDir(); + }); + + // ============================================================ + // KNOWN_TERMINALS_LINUX VALIDATION + // ============================================================ + + describe('KNOWN_TERMINALS_LINUX', () => { + test('is an array with at least 20 entries', () => { + expect(Array.isArray(KNOWN_TERMINALS_LINUX)).toBe(true); + expect(KNOWN_TERMINALS_LINUX.length).toBeGreaterThanOrEqual(20); + }); + + test('includes common GTK/Qt terminal emulators', () => { + expect(KNOWN_TERMINALS_LINUX).toContain('gnome-terminal'); + expect(KNOWN_TERMINALS_LINUX).toContain('konsole'); + expect(KNOWN_TERMINALS_LINUX).toContain('xfce4-terminal'); + expect(KNOWN_TERMINALS_LINUX).toContain('mate-terminal'); + expect(KNOWN_TERMINALS_LINUX).toContain('lxterminal'); + expect(KNOWN_TERMINALS_LINUX).toContain('terminator'); + expect(KNOWN_TERMINALS_LINUX).toContain('tilix'); + expect(KNOWN_TERMINALS_LINUX).toContain('terminology'); + }); + + test('includes GPU-accelerated terminals', () => { + expect(KNOWN_TERMINALS_LINUX).toContain('kitty'); + expect(KNOWN_TERMINALS_LINUX).toContain('alacritty'); + expect(KNOWN_TERMINALS_LINUX).toContain('wezterm'); + expect(KNOWN_TERMINALS_LINUX).toContain('foot'); + expect(KNOWN_TERMINALS_LINUX).toContain('ghostty'); + expect(KNOWN_TERMINALS_LINUX).toContain('rio'); + }); + + test('includes legacy X11 terminals', () => { + expect(KNOWN_TERMINALS_LINUX).toContain('xterm'); + expect(KNOWN_TERMINALS_LINUX).toContain('urxvt'); + expect(KNOWN_TERMINALS_LINUX).toContain('rxvt'); + expect(KNOWN_TERMINALS_LINUX).toContain('st'); + }); + + test('all entries are non-empty strings', () => { + for (const terminal of KNOWN_TERMINALS_LINUX) { + expect(typeof terminal).toBe('string'); + expect(terminal.length).toBeGreaterThan(0); + } + }); + }); + + // ============================================================ + // X11 FOCUS DETECTION + // ============================================================ + + describe('X11 focus detection', () => { + beforeEach(() => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + }); + + // --- Happy paths: known terminals detected --- + + test('detects gnome-terminal focused via xdotool class', async () => { + const shellRunner = createMockShellRunner({ + handler: xdotoolClassHandler('gnome-terminal-server'), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('detects kitty focused via xdotool class', async () => { + const shellRunner = createMockShellRunner({ + handler: xdotoolClassHandler('kitty'), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('detects Alacritty focused via xdotool class (case-insensitive)', async () => { + const shellRunner = createMockShellRunner({ + handler: xdotoolClassHandler('Alacritty'), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('detects konsole focused via xdotool class', async () => { + const shellRunner = createMockShellRunner({ + handler: xdotoolClassHandler('konsole'), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + // --- Non-terminal apps --- + + test('returns false for non-terminal app (firefox)', async () => { + const shellRunner = createMockShellRunner({ + handler: xdotoolClassHandler('firefox'), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('returns false for non-terminal app (nautilus)', async () => { + const shellRunner = createMockShellRunner({ + handler: xdotoolClassHandler('nautilus'), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + // --- Fallback chain: xdotool class → xdotool name → xprop --- + + test('falls back from xdotool class to xdotool name when class is empty', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('getwindowclassname')) { + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + } + if (cmd.includes('getwindowname')) { + return { stdout: Buffer.from('kitty\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('falls back from xdotool class to xdotool name when class throws', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('getwindowclassname')) { + throw new Error('xdotool: Window not found'); + } + if (cmd.includes('getwindowname')) { + return { stdout: Buffer.from('Alacritty\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('falls back to xprop when both xdotool commands fail', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('xdotool')) { + throw new Error('xdotool not found'); + } + if (cmd.includes('xprop -root _NET_ACTIVE_WINDOW')) { + return { + stdout: Buffer.from('_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3a00004\n'), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + if (cmd.includes('xprop -id 0x3a00004')) { + return { + stdout: Buffer.from( + 'WM_CLASS(STRING) = "gnome-terminal-server", "Gnome-terminal"\n' + + 'WM_NAME(UTF8_STRING) = "Terminal"\n', + ), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('xprop detects non-terminal via WM_CLASS', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('xdotool')) { + throw new Error('xdotool not found'); + } + if (cmd.includes('xprop -root _NET_ACTIVE_WINDOW')) { + return { + stdout: Buffer.from('_NET_ACTIVE_WINDOW(WINDOW): window id # 0x4800007\n'), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + if (cmd.includes('xprop -id 0x4800007')) { + return { + stdout: Buffer.from( + 'WM_CLASS(STRING) = "Navigator", "Firefox"\n' + + 'WM_NAME(UTF8_STRING) = "Mozilla Firefox"\n', + ), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + // --- Edge cases --- + + test('returns false when DISPLAY is not set', async () => { + delete process.env.DISPLAY; + + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('kitty\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('returns false when all X11 tools fail (fail-open)', async () => { + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('command not found'); + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('handles xprop returning unparseable active window output', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('xdotool')) { + throw new Error('xdotool not found'); + } + if (cmd.includes('xprop -root')) { + return { + stdout: Buffer.from('_NET_ACTIVE_WINDOW: not found.\n'), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('handles xprop window props returning empty', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('xdotool')) { + throw new Error('xdotool not found'); + } + if (cmd.includes('xprop -root _NET_ACTIVE_WINDOW')) { + return { + stdout: Buffer.from('_NET_ACTIVE_WINDOW(WINDOW): window id # 0x1\n'), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + if (cmd.includes('xprop -id 0x1')) { + return { + stdout: Buffer.from(''), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + }); + + // ============================================================ + // WAYLAND FOCUS DETECTION - SWAY + // ============================================================ + + describe('Wayland focus detection - Sway', () => { + beforeEach(() => { + process.env.XDG_SESSION_TYPE = 'wayland'; + process.env.WAYLAND_DISPLAY = 'wayland-0'; + process.env.XDG_CURRENT_DESKTOP = 'sway'; + process.env.SWAYSOCK = '/run/user/1000/sway-ipc.sock'; + }); + + test('detects terminal via swaymsg app_id', async () => { + const tree = buildSwayTree({ + focused: true, + app_id: 'kitty', + name: 'kitty', + nodes: [], + floating_nodes: [], + }); + + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('swaymsg -t get_tree')) { + return { stdout: Buffer.from(JSON.stringify(tree)), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('detects non-terminal via swaymsg returns false', async () => { + const tree = buildSwayTree({ + focused: true, + app_id: 'firefox', + name: 'Mozilla Firefox', + nodes: [], + floating_nodes: [], + }); + + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('swaymsg -t get_tree')) { + return { stdout: Buffer.from(JSON.stringify(tree)), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('detects terminal via window_properties.class when app_id is missing', async () => { + const tree = buildSwayTree({ + focused: true, + window_properties: { + class: 'Alacritty', + instance: 'alacritty', + }, + nodes: [], + floating_nodes: [], + }); + + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('swaymsg -t get_tree')) { + return { stdout: Buffer.from(JSON.stringify(tree)), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('finds focused node in floating_nodes', async () => { + const tree = buildSwayTreeFloating({ + focused: true, + app_id: 'foot', + name: 'foot', + nodes: [], + floating_nodes: [], + }); + + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('swaymsg -t get_tree')) { + return { stdout: Buffer.from(JSON.stringify(tree)), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('returns false when swaymsg finds no focused node', async () => { + const tree = buildSwayTree({ + focused: false, + app_id: 'kitty', + nodes: [], + floating_nodes: [], + }); + + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('swaymsg')) { + return { stdout: Buffer.from(JSON.stringify(tree)), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('handles swaymsg returning invalid JSON gracefully', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('swaymsg')) { + return { stdout: Buffer.from('not valid json {{{'), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('handles swaymsg command failure', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('swaymsg')) { + throw new Error('swaymsg: unable to connect'); + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('uses window name when app_id and window_properties are absent', async () => { + const tree = buildSwayTree({ + focused: true, + name: 'xterm', + nodes: [], + floating_nodes: [], + }); + + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('swaymsg -t get_tree')) { + return { stdout: Buffer.from(JSON.stringify(tree)), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + }); + + // ============================================================ + // WAYLAND FOCUS DETECTION - GNOME + // ============================================================ + + describe('Wayland focus detection - GNOME', () => { + beforeEach(() => { + process.env.XDG_SESSION_TYPE = 'wayland'; + process.env.WAYLAND_DISPLAY = 'wayland-0'; + process.env.XDG_CURRENT_DESKTOP = 'GNOME'; + }); + + test('detects gnome-terminal focused via gdbus', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('gdbus call')) { + return { + stdout: Buffer.from("(true, 'gnome-terminal-server - Terminal')\n"), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('detects tilix focused via gdbus', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('gdbus call')) { + return { + stdout: Buffer.from("(true, 'tilix - Terminal')\n"), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('returns false for non-terminal via gdbus', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('gdbus call')) { + return { + stdout: Buffer.from("(true, 'Firefox - Mozilla Firefox')\n"), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('handles gdbus returning false (no focused window)', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('gdbus call')) { + return { + stdout: Buffer.from("(false, '')\n"), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('handles gdbus returning empty string value', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('gdbus call')) { + return { + stdout: Buffer.from("(true, '')\n"), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('handles gdbus command failure and falls back', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('gdbus')) { + throw new Error('gdbus not available'); + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + }); + + // ============================================================ + // WAYLAND FOCUS DETECTION - KDE + // ============================================================ + + describe('Wayland focus detection - KDE', () => { + beforeEach(() => { + process.env.XDG_SESSION_TYPE = 'wayland'; + process.env.WAYLAND_DISPLAY = 'wayland-0'; + process.env.XDG_CURRENT_DESKTOP = 'KDE'; + }); + + test('detects konsole focused via qdbus windowClass', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('org.kde.KWin.activeWindow')) { + return { stdout: Buffer.from('42\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + if (cmd.includes('org.kde.KWin.caption')) { + return { stdout: Buffer.from('Terminal - Konsole\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + if (cmd.includes('org.kde.KWin.windowClass')) { + return { stdout: Buffer.from('konsole\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('returns false for non-terminal via qdbus', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('org.kde.KWin.activeWindow')) { + return { stdout: Buffer.from('99\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + if (cmd.includes('org.kde.KWin.caption')) { + return { stdout: Buffer.from('Dolphin - Files\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + if (cmd.includes('org.kde.KWin.windowClass')) { + return { stdout: Buffer.from('dolphin\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('uses caption as fallback when windowClass is empty', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('org.kde.KWin.activeWindow')) { + return { stdout: Buffer.from('55\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + if (cmd.includes('org.kde.KWin.caption')) { + return { stdout: Buffer.from('kitty\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + if (cmd.includes('org.kde.KWin.windowClass')) { + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('uses activeWindow ID as last resort when caption and class are empty', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('org.kde.KWin.activeWindow')) { + return { stdout: Buffer.from('77\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + if (cmd.includes('org.kde.KWin.caption') || cmd.includes('org.kde.KWin.windowClass')) { + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + // Window ID '77' is not a known terminal + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('returns false when qdbus activeWindow fails', async () => { + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('qdbus')) { + throw new Error('qdbus not found'); + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + }); + + // ============================================================ + // SESSION TYPE ROUTING + // ============================================================ + + describe('session type routing', () => { + test('XDG_SESSION_TYPE=x11 routes to X11 detection path', async () => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + + const commandsCalled = []; + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + commandsCalled.push(cmd); + if (cmd.includes('xdotool getwindowfocus getwindowclassname')) { + return { stdout: Buffer.from('kitty\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + expect(commandsCalled.some((c) => c.includes('xdotool'))).toBe(true); + expect(commandsCalled.some((c) => c.includes('swaymsg'))).toBe(false); + expect(commandsCalled.some((c) => c.includes('gdbus'))).toBe(false); + expect(commandsCalled.some((c) => c.includes('qdbus'))).toBe(false); + }); + + test('XDG_SESSION_TYPE=wayland routes to Wayland detection path', async () => { + process.env.XDG_SESSION_TYPE = 'wayland'; + process.env.WAYLAND_DISPLAY = 'wayland-0'; + process.env.XDG_CURRENT_DESKTOP = 'sway'; + + const commandsCalled = []; + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + commandsCalled.push(cmd); + if (cmd.includes('swaymsg -t get_tree')) { + return { + stdout: Buffer.from(JSON.stringify(buildSwayTree({ + focused: true, + app_id: 'foot', + nodes: [], + floating_nodes: [], + }))), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + expect(commandsCalled.some((c) => c.includes('swaymsg'))).toBe(true); + expect(commandsCalled.some((c) => c.includes('xdotool'))).toBe(false); + }); + + test('XDG_SESSION_TYPE=tty returns false without shell calls', async () => { + process.env.XDG_SESSION_TYPE = 'tty'; + + const shellRunner = createMockShellRunner({ + handler: () => ({ + stdout: Buffer.from('kitty\n'), + stderr: Buffer.from(''), + exitCode: 0, + }), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + expect(shellRunner.getCallCount()).toBe(0); + }); + + test('unknown session type tries X11 first, then Wayland', async () => { + // No session type env vars; set DISPLAY for X11 to work + process.env.DISPLAY = ':0'; + + const commandsCalled = []; + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + commandsCalled.push(cmd); + if (cmd.includes('xdotool getwindowfocus getwindowclassname')) { + return { stdout: Buffer.from('konsole\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + // X11 should be tried first + expect(commandsCalled[0]).toContain('xdotool'); + }); + + test('unknown session falls through to Wayland when X11 path returns null', async () => { + // No DISPLAY → X11 returns null → tries Wayland + process.env.SWAYSOCK = '/run/user/1000/sway-ipc.sock'; + + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('swaymsg -t get_tree')) { + return { + stdout: Buffer.from(JSON.stringify(buildSwayTree({ + focused: true, + app_id: 'alacritty', + nodes: [], + floating_nodes: [], + }))), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('WAYLAND_DISPLAY without XDG_SESSION_TYPE detects wayland session', async () => { + process.env.WAYLAND_DISPLAY = 'wayland-0'; + process.env.XDG_CURRENT_DESKTOP = 'GNOME'; + + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('gdbus call')) { + return { + stdout: Buffer.from("(true, 'gnome-terminal-server')\n"), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('DISPLAY without XDG_SESSION_TYPE detects x11 session', async () => { + process.env.DISPLAY = ':0'; + + const shellRunner = createMockShellRunner({ + handler: xdotoolClassHandler('kitty'), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + }); + + // ============================================================ + // WAYLAND DESKTOP ENVIRONMENT DETECTION + // ============================================================ + + describe('Wayland desktop environment detection', () => { + beforeEach(() => { + process.env.XDG_SESSION_TYPE = 'wayland'; + process.env.WAYLAND_DISPLAY = 'wayland-0'; + }); + + test('detects Sway via SWAYSOCK env var', async () => { + process.env.SWAYSOCK = '/run/user/1000/sway-ipc.sock'; + + const commandsCalled = []; + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + commandsCalled.push(cmd); + if (cmd.includes('swaymsg')) { + return { + stdout: Buffer.from(JSON.stringify(buildSwayTree({ + focused: true, + app_id: 'kitty', + nodes: [], + floating_nodes: [], + }))), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + await isTerminalFocused({ shellRunner }); + expect(commandsCalled[0]).toContain('swaymsg'); + }); + + test('detects Sway via XDG_CURRENT_DESKTOP', async () => { + process.env.XDG_CURRENT_DESKTOP = 'sway'; + + const commandsCalled = []; + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + commandsCalled.push(cmd); + if (cmd.includes('swaymsg')) { + return { + stdout: Buffer.from(JSON.stringify(buildSwayTree({ + focused: true, + app_id: 'foot', + nodes: [], + floating_nodes: [], + }))), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + await isTerminalFocused({ shellRunner }); + expect(commandsCalled[0]).toContain('swaymsg'); + }); + + test('detects GNOME via XDG_CURRENT_DESKTOP', async () => { + process.env.XDG_CURRENT_DESKTOP = 'GNOME'; + + const commandsCalled = []; + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + commandsCalled.push(cmd); + if (cmd.includes('gdbus call')) { + return { + stdout: Buffer.from("(true, 'kitty')\n"), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + await isTerminalFocused({ shellRunner }); + expect(commandsCalled[0]).toContain('gdbus'); + }); + + test('detects KDE via XDG_CURRENT_DESKTOP containing plasma', async () => { + process.env.XDG_CURRENT_DESKTOP = 'KDE:plasma'; + + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('org.kde.KWin.activeWindow')) { + return { stdout: Buffer.from('42\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + if (cmd.includes('org.kde.KWin.windowClass')) { + return { stdout: Buffer.from('konsole\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + if (cmd.includes('org.kde.KWin.caption')) { + return { stdout: Buffer.from('Konsole\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + + test('detects desktop from XDG_SESSION_DESKTOP', async () => { + process.env.XDG_SESSION_DESKTOP = 'gnome'; + + const commandsCalled = []; + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + commandsCalled.push(cmd); + if (cmd.includes('gdbus call')) { + return { + stdout: Buffer.from("(true, 'code')\n"), + stderr: Buffer.from(''), + exitCode: 0, + }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + await isTerminalFocused({ shellRunner }); + expect(commandsCalled[0]).toContain('gdbus'); + }); + + test('unknown desktop environment tries all methods in fallback chain', async () => { + // No desktop env vars → unknown DE + const commandsCalled = []; + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + commandsCalled.push(cmd); + throw new Error('not available'); + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + // Should have tried sway, gnome, and kde as fallbacks + expect(commandsCalled.some((c) => c.includes('swaymsg'))).toBe(true); + expect(commandsCalled.some((c) => c.includes('gdbus'))).toBe(true); + expect(commandsCalled.some((c) => c.includes('qdbus'))).toBe(true); + }); + + test('known DE with specific failure still tries fallback chain', async () => { + process.env.XDG_CURRENT_DESKTOP = 'sway'; + + const commandsCalled = []; + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + commandsCalled.push(cmd); + // All fail + throw new Error('not available'); + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + // Sway tried as specific DE method AND again in fallback + const swayCalls = commandsCalled.filter((c) => c.includes('swaymsg')); + expect(swayCalls.length).toBeGreaterThanOrEqual(1); + // Also tried gnome and kde in fallback + expect(commandsCalled.some((c) => c.includes('gdbus'))).toBe(true); + expect(commandsCalled.some((c) => c.includes('qdbus'))).toBe(true); + }); + }); + + // ============================================================ + // ERROR HANDLING, FAIL-OPEN, AND DEBUG LOGGING + // ============================================================ + + describe('error handling and fail-open behavior', () => { + test('returns false when all Linux detection methods fail (fail-open)', async () => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('all commands unavailable'); + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + + test('never throws even on catastrophic shell errors', async () => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + + const shellRunner = createMockShellRunner({ + handler: () => { + throw new TypeError('Cannot read properties of undefined'); + }, + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(typeof result).toBe('boolean'); + expect(result).toBe(false); + }); + + test('updates cache even when detection fails', async () => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('fail'); + }, + }); + + await isTerminalFocused({ shellRunner }); + + const cache = getCacheState(); + expect(cache.timestamp).toBeGreaterThan(0); + expect(cache.isFocused).toBe(false); + expect(cache.terminalName).toBeNull(); + }); + }); + + describe('debug logging to file on errors', () => { + test('writes debug log entries when debugLog is enabled', async () => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + createTestLogsDir(); + + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('xdotool crashed'); + }, + }); + + await isTerminalFocused({ debugLog: true, shellRunner }); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + expect(logContent).not.toBeNull(); + expect(logContent).toContain('[focus-detect]'); + }); + + test('debug log contains session type information', async () => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + createTestLogsDir(); + + const shellRunner = createMockShellRunner({ + handler: xdotoolClassHandler('kitty'), + }); + + await isTerminalFocused({ debugLog: true, shellRunner }); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + expect(logContent).not.toBeNull(); + expect(logContent).toContain('[focus-detect]'); + }); + + test('debug log contains error details on command failure', async () => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + createTestLogsDir(); + + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('xdotool: BadWindow'); + }, + }); + + await isTerminalFocused({ debugLog: true, shellRunner }); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + expect(logContent).not.toBeNull(); + expect(logContent).toContain('xdotool: BadWindow'); + }); + + test('does NOT write debug log when debugLog is disabled', async () => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + + const shellRunner = createMockShellRunner({ + handler: () => { + throw new Error('xdotool crashed'); + }, + }); + + await isTerminalFocused({ debugLog: false, shellRunner }); + + const logContent = readTestFile('logs/smart-voice-notify-debug.log'); + expect(logContent).toBeNull(); + }); + }); + + // ============================================================ + // CACHE BEHAVIOR UNDER LINUX MOCKING + // ============================================================ + + describe('cache behavior under Linux mocking', () => { + test('cache prevents repeated shell calls within TTL', async () => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + + const shellRunner = createMockShellRunner({ + handler: xdotoolClassHandler('kitty'), + }); + + const result1 = await isTerminalFocused({ shellRunner }); + expect(result1).toBe(true); + const callsAfterFirst = shellRunner.getCallCount(); + expect(callsAfterFirst).toBeGreaterThan(0); + + // Second call should use cache, no new shell calls + const result2 = await isTerminalFocused({ shellRunner }); + expect(result2).toBe(true); + expect(shellRunner.getCallCount()).toBe(callsAfterFirst); + }); + + test('clearFocusCache forces fresh detection on next call', async () => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + + let xdotoolCallCount = 0; + const shellRunner = createMockShellRunner({ + handler: (cmd) => { + if (cmd.includes('xdotool getwindowfocus getwindowclassname')) { + xdotoolCallCount++; + return { stdout: Buffer.from('kitty\n'), stderr: Buffer.from(''), exitCode: 0 }; + } + return { stdout: Buffer.from(''), stderr: Buffer.from(''), exitCode: 0 }; + }, + }); + + await isTerminalFocused({ shellRunner }); + expect(xdotoolCallCount).toBe(1); + + clearFocusCache(); + + await isTerminalFocused({ shellRunner }); + expect(xdotoolCallCount).toBe(2); + }); + }); + + // ============================================================ + // KNOWN TERMINALS COVERAGE - PARAMETRIZED + // ============================================================ + + describe('known Linux terminals recognition via X11', () => { + const terminalSubset = [ + 'gnome-terminal', + 'gnome-terminal-server', + 'konsole', + 'xfce4-terminal', + 'mate-terminal', + 'lxterminal', + 'terminator', + 'tilix', + 'terminology', + 'kitty', + 'alacritty', + 'wezterm', + 'foot', + 'xterm', + 'urxvt', + 'st', + 'ghostty', + 'rio', + 'hyper', + 'tabby', + 'warp', + ]; + + for (const terminal of terminalSubset) { + test(`recognizes "${terminal}" as a terminal`, async () => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + clearFocusCache(); + + const shellRunner = createMockShellRunner({ + handler: xdotoolClassHandler(terminal), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(true); + }); + } + }); + + describe('non-terminal apps are NOT recognized as terminals', () => { + const nonTerminals = [ + 'firefox', + 'chromium', + 'nautilus', + 'libreoffice', + 'spotify', + 'slack', + 'discord', + 'gimp', + 'thunderbird', + 'evince', + ]; + + for (const app of nonTerminals) { + test(`does NOT recognize "${app}" as a terminal`, async () => { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.DISPLAY = ':0'; + clearFocusCache(); + + const shellRunner = createMockShellRunner({ + handler: xdotoolClassHandler(app), + }); + + const result = await isTerminalFocused({ shellRunner }); + expect(result).toBe(false); + }); + } + }); +}); diff --git a/tests/unit/linux.test.js b/tests/unit/linux.test.ts similarity index 99% rename from tests/unit/linux.test.js rename to tests/unit/linux.test.ts index b60451d..1c12654 100644 --- a/tests/unit/linux.test.js +++ b/tests/unit/linux.test.ts @@ -1,5 +1,6 @@ +// @ts-nocheck import { describe, expect, it, beforeEach, afterEach } from 'bun:test'; -import { createLinuxPlatform } from '../../util/linux.js'; +import { createLinuxPlatform } from '../../src/util/linux.js'; import { createMockShellRunner } from '../setup.js'; describe('Linux Platform Compatibility', () => { diff --git a/tests/unit/per-project-sound.test.js b/tests/unit/per-project-sound.test.ts similarity index 97% rename from tests/unit/per-project-sound.test.js rename to tests/unit/per-project-sound.test.ts index f1b9ba1..c3e1acd 100644 --- a/tests/unit/per-project-sound.test.js +++ b/tests/unit/per-project-sound.test.ts @@ -1,6 +1,7 @@ +// @ts-nocheck import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import path from 'path'; -import { getProjectSound, clearProjectSoundCache } from '../../util/per-project-sound.js'; +import { getProjectSound, clearProjectSoundCache } from '../../src/util/per-project-sound.js'; import { createTestTempDir, cleanupTestTempDir } from '../setup.js'; describe('Per-Project Sound Module', () => { diff --git a/tests/unit/sound-theme.test.js b/tests/unit/sound-theme.test.ts similarity index 99% rename from tests/unit/sound-theme.test.js rename to tests/unit/sound-theme.test.ts index bc4cb44..c358502 100644 --- a/tests/unit/sound-theme.test.js +++ b/tests/unit/sound-theme.test.ts @@ -1,9 +1,10 @@ +// @ts-nocheck import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { createTestTempDir, cleanupTestTempDir } from '../setup.js'; -import { listSoundsInTheme, pickThemeSound, pickRandomSound } from '../../util/sound-theme.js'; +import { listSoundsInTheme, pickThemeSound, pickRandomSound } from '../../src/util/sound-theme.js'; describe('Sound Theme Module', () => { let tempDir; diff --git a/tests/unit/sub-session-filtering.test.ts b/tests/unit/sub-session-filtering.test.ts new file mode 100644 index 0000000..0a6cc6c --- /dev/null +++ b/tests/unit/sub-session-filtering.test.ts @@ -0,0 +1,801 @@ +// @ts-nocheck +/** + * Unit Tests for Sub-Session Filtering Edge Cases + * + * Comprehensive tests to verify that sub-session detection correctly + * prevents spurious notifications, and that the fail-safe (API error) + * path also suppresses notifications. Covers both session.idle and + * session.error handlers. + * + * Key behaviors under test: + * - Main sessions (parentID null) SHOULD trigger notifications + * - Sub-sessions (parentID set) SHOULD NOT trigger notifications + * - API call failures SHOULD trigger fallback notifications with generic messages + * - Debounce entries are cleared when a sub-session or API error is detected + * + * @see src/index.ts - session.idle handler (lines ~1189-1303) + * @see src/index.ts - session.error handler (lines ~1311-1388) + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import SmartVoiceNotifyPlugin from '../../src/index.js'; +import { + createTestTempDir, + cleanupTestTempDir, + createTestConfig, + createMinimalConfig, + createTestAssets, + createMockShellRunner, + createMockClient, + mockEvents, + readTestFile, + wait, +} from '../setup.js'; + +describe('Sub-Session Filtering', () => { + let mockClient; + let mockShell; + let tempDir; + + beforeEach(() => { + tempDir = createTestTempDir(); + createTestAssets(); + mockClient = createMockClient(); + mockShell = createMockShellRunner(); + }); + + afterEach(() => { + cleanupTestTempDir(); + }); + + // ============================================================ + // HELPER: Initialize plugin with minimal config + // ============================================================ + + const initPlugin = async (overrides = {}) => { + createTestConfig( + createMinimalConfig({ + enabled: true, + enableSound: true, + enableToast: true, + enableDesktopNotification: false, + enableWebhook: false, + enableTTSReminder: false, + enableAIMessages: false, + enableIdleNotification: true, + enableErrorNotification: true, + debugLog: false, + idleSound: 'assets/test-sound.mp3', + errorSound: 'assets/test-sound.mp3', + ...overrides, + }), + ); + + return SmartVoiceNotifyPlugin({ + project: { id: 'test-project' }, + client: mockClient, + $: mockShell, + directory: tempDir, + worktree: tempDir, + }); + }; + + // ============================================================ + // SESSION.IDLE HANDLER - Sub-session filtering + // ============================================================ + + describe('session.idle handler', () => { + // -------------------------------------------------------- + // Happy path: main session triggers notification + // -------------------------------------------------------- + + test('should trigger notification for main session (parentID null)', async () => { + // Arrange + const sessionId = 'main-session-001'; + mockClient.session.setMockSession(sessionId, { + parentID: null, + status: 'idle', + }); + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Assert - toast was shown (notification triggered) + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + expect(toastCalls[0].message).toContain('Agent has finished'); + + // Assert - sound was played + expect(mockShell.getCallCount()).toBeGreaterThan(0); + expect(mockShell.wasCalledWith('test-sound.mp3')).toBe(true); + }); + + test('should trigger notification when session has no parentID property at all', async () => { + // Arrange - default mock session has parentID: null + const sessionId = 'no-parent-prop-session'; + // The default mock returns { id, parentID: null, status: 'idle' } + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Assert - notification triggered + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + expect(mockShell.getCallCount()).toBeGreaterThan(0); + }); + + // -------------------------------------------------------- + // Sub-session: parentID set -> skip notification + // -------------------------------------------------------- + + test('should NOT trigger notification for sub-session (parentID set)', async () => { + // Arrange + const sessionId = 'sub-session-001'; + mockClient.session.setMockSession(sessionId, { + parentID: 'parent-session-001', + status: 'idle', + }); + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Assert - no toast, no sound + expect(mockClient.tui.getToastCalls().length).toBe(0); + expect(mockShell.getCallCount()).toBe(0); + }); + + test('should NOT trigger notification when parentID is a non-empty string', async () => { + // Arrange - any truthy parentID should be filtered + const sessionId = 'sub-session-truthy'; + mockClient.session.setMockSession(sessionId, { + parentID: 'any-truthy-value', + status: 'idle', + }); + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Assert + expect(mockClient.tui.getToastCalls().length).toBe(0); + expect(mockShell.getCallCount()).toBe(0); + }); + + // -------------------------------------------------------- + // API call fails -> fallback: send generic notification + // -------------------------------------------------------- + + test('should trigger fallback notification when session.get API call throws', async () => { + // Arrange - override session.get to throw + const sessionId = 'api-fail-session'; + mockClient.session.get = async () => { + throw new Error('Network error: API unavailable'); + }; + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Assert - fallback notification sent (generic message, no session context) + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + expect(toastCalls[0].message).toContain('Agent has finished'); + }); + + test('should NOT trigger notification when session.get returns undefined data', async () => { + // Arrange - API returns structure without data + const sessionId = 'api-null-data-session'; + mockClient.session.get = async () => ({ data: undefined }); + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Assert - parentID is checked via sessionData?.parentID + // When data is undefined, sessionData is null, so parentID check is falsy + // The code sets sessionData = session?.data ?? null, then checks sessionData?.parentID + // Since sessionData is null, parentID is undefined (falsy) -> proceeds to notification + // This is the designed behavior: if we got a response but no data, treat as main session + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + }); + + // -------------------------------------------------------- + // Debounce entry cleared when sub-session is detected + // -------------------------------------------------------- + + test('should clear debounce entry when sub-session is detected', async () => { + // Arrange + const sessionId = 'debounce-sub-session'; + + // First: set up as sub-session + mockClient.session.setMockSession(sessionId, { + parentID: 'parent-session', + status: 'idle', + }); + const plugin = await initPlugin(); + + // Act 1: fire idle for sub-session -> sets debounce then clears it + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Verify no notification was sent (sub-session) + expect(mockClient.tui.getToastCalls().length).toBe(0); + + // Act 2: now change to main session and fire idle again + // If debounce was NOT cleared, this would be debounced and skipped. + // If debounce WAS cleared, this should trigger a notification. + mockClient.session.setMockSession(sessionId, { + parentID: null, + status: 'idle', + }); + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Assert - notification triggered (debounce was cleared by sub-session detection) + expect(mockClient.tui.getToastCalls().length).toBe(1); + expect(mockClient.tui.getToastCalls()[0].message).toContain('Agent has finished'); + }); + + // -------------------------------------------------------- + // Debounce entry cleared when API call fails + // -------------------------------------------------------- + + test('should clear debounce entry when API call fails', async () => { + // Arrange + const sessionId = 'debounce-api-fail'; + const originalGet = mockClient.session.get.bind(mockClient.session); + + // First call: API fails + let callCount = 0; + mockClient.session.get = async (input) => { + callCount++; + if (callCount === 1) { + throw new Error('Transient API error'); + } + // Subsequent calls succeed - return main session + return originalGet(input); + }; + const plugin = await initPlugin(); + + // Act 1: fire idle -> API fails, fallback notification sent, debounce entry cleared + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Verify fallback notification sent (API failed = fallback with generic message) + expect(mockClient.tui.getToastCalls().length).toBe(1); + expect(mockClient.tui.getToastCalls()[0].message).toContain('Agent has finished'); + + // Act 2: fire idle again -> API succeeds, main session + // If debounce was NOT cleared, this would be debounced and skipped. + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Assert - second notification triggered (debounce was cleared by API failure) + expect(mockClient.tui.getToastCalls().length).toBe(2); + }); + + // -------------------------------------------------------- + // Debounce still active for successful main session + // -------------------------------------------------------- + + test('should debounce rapid duplicate idle events for main sessions', async () => { + // Arrange + const sessionId = 'debounce-main-session'; + mockClient.session.setMockSession(sessionId, { + parentID: null, + status: 'idle', + }); + const plugin = await initPlugin(); + + // Act - fire same session.idle three times rapidly + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Assert - only ONE notification (debounce active after first success) + expect(mockClient.tui.getToastCalls().length).toBe(1); + }); + + // -------------------------------------------------------- + // No sessionID -> early return, no API call + // -------------------------------------------------------- + + test('should silently return when sessionID is missing', async () => { + // Arrange + const plugin = await initPlugin(); + + // Act - event with no sessionID + const event = { type: 'session.idle', properties: {} }; + await plugin.event({ event }); + + // Assert - no toast, no sound, no API call + expect(mockClient.tui.getToastCalls().length).toBe(0); + expect(mockShell.getCallCount()).toBe(0); + }); + + // -------------------------------------------------------- + // Idle notification disabled via config + // -------------------------------------------------------- + + test('should skip when enableIdleNotification is false', async () => { + // Arrange + const sessionId = 'disabled-idle-session'; + mockClient.session.setMockSession(sessionId, { + parentID: null, + status: 'idle', + }); + const plugin = await initPlugin({ enableIdleNotification: false }); + + // Act + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Assert + expect(mockClient.tui.getToastCalls().length).toBe(0); + expect(mockShell.getCallCount()).toBe(0); + }); + }); + + // ============================================================ + // SESSION.ERROR HANDLER - Sub-session filtering + // ============================================================ + + describe('session.error handler', () => { + // -------------------------------------------------------- + // Happy path: main session error triggers notification + // -------------------------------------------------------- + + test('should trigger notification for main session error (parentID null)', async () => { + // Arrange + const sessionId = 'main-error-session-001'; + mockClient.session.setMockSession(sessionId, { + parentID: null, + status: 'error', + }); + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert - toast shown with error variant + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + expect(toastCalls[0].message).toContain('error'); + expect(toastCalls[0].variant).toBe('error'); + + // Assert - sound was played + expect(mockShell.getCallCount()).toBeGreaterThan(0); + expect(mockShell.wasCalledWith('test-sound.mp3')).toBe(true); + }); + + test('should trigger notification when error session has no parentID property', async () => { + // Arrange - default mock session has parentID: null + const sessionId = 'error-no-parent-prop'; + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert - notification triggered + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + expect(toastCalls[0].variant).toBe('error'); + }); + + // -------------------------------------------------------- + // Sub-session error: parentID set -> skip notification + // -------------------------------------------------------- + + test('should NOT trigger notification for sub-session error (parentID set)', async () => { + // Arrange + const sessionId = 'sub-error-session-001'; + mockClient.session.setMockSession(sessionId, { + parentID: 'parent-session-001', + status: 'error', + }); + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert - no toast, no sound + expect(mockClient.tui.getToastCalls().length).toBe(0); + expect(mockShell.getCallCount()).toBe(0); + }); + + test('should NOT trigger notification for sub-session error with any truthy parentID', async () => { + // Arrange + const sessionId = 'sub-error-truthy-parent'; + mockClient.session.setMockSession(sessionId, { + parentID: 'some-parent-id', + status: 'error', + }); + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert + expect(mockClient.tui.getToastCalls().length).toBe(0); + expect(mockShell.getCallCount()).toBe(0); + }); + + // -------------------------------------------------------- + // API call fails -> fallback: send generic notification + // -------------------------------------------------------- + + test('should trigger fallback notification when session.get throws for error event', async () => { + // Arrange - override session.get to throw + const sessionId = 'api-fail-error-session'; + mockClient.session.get = async () => { + throw new Error('Connection refused'); + }; + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert - fallback notification sent (generic error message) + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + expect(toastCalls[0].message).toContain('error'); + expect(toastCalls[0].variant).toBe('error'); + }); + + test('should trigger fallback notification when session.get throws TypeError', async () => { + // Arrange - simulate a different type of API error + const sessionId = 'api-typeerror-session'; + mockClient.session.get = async () => { + throw new TypeError('Cannot read properties of undefined'); + }; + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert - fallback notification sent + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + expect(toastCalls[0].message).toContain('error'); + expect(toastCalls[0].variant).toBe('error'); + }); + + // -------------------------------------------------------- + // No sessionID -> early return, no API call + // -------------------------------------------------------- + + test('should silently return when error event has no sessionID', async () => { + // Arrange + const plugin = await initPlugin(); + + // Act + const event = { type: 'session.error', properties: {} }; + await plugin.event({ event }); + + // Assert + expect(mockClient.tui.getToastCalls().length).toBe(0); + expect(mockShell.getCallCount()).toBe(0); + }); + + // -------------------------------------------------------- + // Error notification disabled via config + // -------------------------------------------------------- + + test('should skip when enableErrorNotification is false', async () => { + // Arrange + const sessionId = 'disabled-error-session'; + mockClient.session.setMockSession(sessionId, { + parentID: null, + status: 'error', + }); + const plugin = await initPlugin({ enableErrorNotification: false }); + + // Act + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert + expect(mockClient.tui.getToastCalls().length).toBe(0); + expect(mockShell.getCallCount()).toBe(0); + }); + }); + + // ============================================================ + // CROSS-HANDLER: Interaction between idle and error events + // ============================================================ + + describe('cross-handler interactions', () => { + test('sub-session idle skip should not affect main session error notification', async () => { + // Arrange - same session ID, idle as sub-session, error as main + const sessionId = 'cross-handler-session'; + + // Set up as sub-session for idle (will be skipped) + mockClient.session.setMockSession(sessionId, { + parentID: 'parent-session', + status: 'idle', + }); + const plugin = await initPlugin(); + + // Act 1: idle fires for sub-session -> skipped + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + expect(mockClient.tui.getToastCalls().length).toBe(0); + + // Act 2: change to main session, error fires -> should notify + mockClient.session.setMockSession(sessionId, { + parentID: null, + status: 'error', + }); + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert - error notification was sent + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + expect(toastCalls[0].variant).toBe('error'); + }); + + test('API failure on idle should not affect subsequent error notification', async () => { + // Arrange + const sessionId = 'api-fail-then-error'; + let callCount = 0; + const originalGet = mockClient.session.get.bind(mockClient.session); + + mockClient.session.get = async (input) => { + callCount++; + if (callCount === 1) { + throw new Error('Transient failure'); + } + return originalGet(input); + }; + const plugin = await initPlugin(); + + // Act 1: idle fires -> API fails, fallback notification sent + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + expect(mockClient.tui.getToastCalls().length).toBe(1); + + // Act 2: error fires -> API succeeds, main session + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert - error notification was also sent (2 total: fallback idle + normal error) + expect(mockClient.tui.getToastCalls().length).toBe(2); + }); + + test('multiple sub-sessions should all be silently filtered', async () => { + // Arrange - several sub-sessions + const subIds = ['sub-1', 'sub-2', 'sub-3']; + for (const id of subIds) { + mockClient.session.setMockSession(id, { + parentID: 'main-parent', + status: 'idle', + }); + } + const plugin = await initPlugin(); + + // Act - fire idle for each sub-session + for (const id of subIds) { + await plugin.event({ event: mockEvents.sessionIdle(id) }); + } + + // Assert - zero notifications + expect(mockClient.tui.getToastCalls().length).toBe(0); + expect(mockShell.getCallCount()).toBe(0); + }); + + test('main session after several filtered sub-sessions should still notify', async () => { + // Arrange + const subIds = ['sub-a', 'sub-b']; + for (const id of subIds) { + mockClient.session.setMockSession(id, { + parentID: 'parent-main', + status: 'idle', + }); + } + mockClient.session.setMockSession('main-session', { + parentID: null, + status: 'idle', + }); + const plugin = await initPlugin(); + + // Act - fire idle for sub-sessions, then main + for (const id of subIds) { + await plugin.event({ event: mockEvents.sessionIdle(id) }); + } + await plugin.event({ event: mockEvents.sessionIdle('main-session') }); + + // Assert - exactly one notification (for main session only) + expect(mockClient.tui.getToastCalls().length).toBe(1); + }); + }); + + // ============================================================ + // SESSION CACHE: API call reduction and freshness + // ============================================================ + + describe('session cache behavior', () => { + test('should reuse cached session data across idle and error events within TTL', async () => { + // Arrange + const sessionId = 'cache-reuse-main-session'; + mockClient.session.setMockSession(sessionId, { + parentID: null, + status: 'idle', + }); + + let sessionGetCalls = 0; + const originalGet = mockClient.session.get.bind(mockClient.session); + mockClient.session.get = async (input) => { + sessionGetCalls++; + return originalGet(input); + }; + + const plugin = await initPlugin(); + + // Act - first event fetches and caches, second event should use cache + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert - only one API call for both events + expect(sessionGetCalls).toBe(1); + }); + + test('should refresh cache after TTL expires', async () => { + // Arrange + const sessionId = 'cache-expiry-session'; + mockClient.session.setMockSession(sessionId, { + parentID: null, + status: 'idle', + }); + + let sessionGetCalls = 0; + const originalGet = mockClient.session.get.bind(mockClient.session); + mockClient.session.get = async (input) => { + sessionGetCalls++; + return originalGet(input); + }; + + const realDateNow = Date.now; + let fakeNow = 1_000; + Date.now = () => fakeNow; + + try { + const plugin = await initPlugin(); + + // Act 1 - initial fetch and cache write + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + expect(sessionGetCalls).toBe(1); + + // Advance beyond 30s TTL, then trigger another event + fakeNow += 30_001; + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert - stale cache forced a second API call + expect(sessionGetCalls).toBe(2); + } finally { + Date.now = realDateNow; + } + }); + + test('should clear session cache on session.created for that session', async () => { + // Arrange + const sessionId = 'cache-clear-on-created'; + mockClient.session.setMockSession(sessionId, { + parentID: null, + status: 'idle', + }); + + let sessionGetCalls = 0; + const originalGet = mockClient.session.get.bind(mockClient.session); + mockClient.session.get = async (input) => { + sessionGetCalls++; + return originalGet(input); + }; + + const plugin = await initPlugin(); + + // Act 1 - cache main-session result + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + expect(sessionGetCalls).toBe(1); + + // Update backing session data to sub-session and clear cache via session.created + mockClient.session.setMockSession(sessionId, { + parentID: 'parent-after-created', + status: 'error', + }); + await plugin.event({ event: mockEvents.sessionCreated(sessionId) }); + + // Act 2 - should re-fetch (cache cleared) and skip notification as sub-session + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert - API called again after session.created cache invalidation + expect(sessionGetCalls).toBe(2); + + // Assert - only the first idle notification was sent (error was filtered) + expect(mockClient.tui.getToastCalls().length).toBe(1); + }); + + test('should write cache hit and miss debug logs', async () => { + // Arrange + const sessionId = 'cache-debug-log-session'; + mockClient.session.setMockSession(sessionId, { + parentID: null, + status: 'idle', + }); + + const plugin = await initPlugin({ debugLog: true }); + + // Act - first is miss, second should be hit + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert + const debugLogContent = readTestFile('logs/smart-voice-notify-debug.log') || ''; + expect(debugLogContent.includes(`session.idle: session cache miss for ${sessionId}`)).toBe(true); + expect(debugLogContent.includes(`session.error: session cache hit for ${sessionId}`)).toBe(true); + }); + }); + + // ============================================================ + // ERROR RESILIENCE: Various failure modes + // ============================================================ + + describe('error resilience', () => { + test('session.get returning null session should not crash idle handler', async () => { + // Arrange + const sessionId = 'null-session'; + mockClient.session.get = async () => ({ data: null }); + const plugin = await initPlugin(); + + // Act - should not throw + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Assert - notification proceeds (null data means no parentID -> treat as main) + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + }); + + test('session.get returning empty object should not crash error handler', async () => { + // Arrange + const sessionId = 'empty-session'; + mockClient.session.get = async () => ({ data: {} }); + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert - no parentID on empty object -> proceeds with notification + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + }); + + test('non-Error thrown from session.get should be handled gracefully', async () => { + // Arrange - throw a string instead of Error + const sessionId = 'string-throw-session'; + mockClient.session.get = async () => { + throw 'unexpected string error'; + }; + const plugin = await initPlugin(); + + // Act - should not crash + await plugin.event({ event: mockEvents.sessionIdle(sessionId) }); + + // Assert - fallback notification sent (graceful handling of non-Error) + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + expect(toastCalls[0].message).toContain('Agent has finished'); + }); + + test('session.get rejecting with undefined should be handled', async () => { + // Arrange + const sessionId = 'undefined-reject-session'; + mockClient.session.get = async () => { + throw undefined; + }; + const plugin = await initPlugin(); + + // Act + await plugin.event({ event: mockEvents.sessionError(sessionId) }); + + // Assert - fallback notification sent (graceful handling of undefined rejection) + const toastCalls = mockClient.tui.getToastCalls(); + expect(toastCalls.length).toBe(1); + expect(toastCalls[0].message).toContain('error'); + expect(toastCalls[0].variant).toBe('error'); + }); + }); +}); diff --git a/tests/unit/tts.test.js b/tests/unit/tts.test.ts similarity index 99% rename from tests/unit/tts.test.js rename to tests/unit/tts.test.ts index a8ae346..70c7e9b 100644 --- a/tests/unit/tts.test.js +++ b/tests/unit/tts.test.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; import path from 'path'; import fs from 'fs'; @@ -35,7 +36,7 @@ mock.module('msedge-tts', () => ({ } })); -import { getTTSConfig, createTTS } from '../../util/tts.js'; +import { getTTSConfig, createTTS } from '../../src/util/tts.js'; import { createTestTempDir, cleanupTestTempDir, diff --git a/tests/unit/webhook.test.js b/tests/unit/webhook.test.ts similarity index 99% rename from tests/unit/webhook.test.js rename to tests/unit/webhook.test.ts index 13a6ff9..51fae28 100644 --- a/tests/unit/webhook.test.js +++ b/tests/unit/webhook.test.ts @@ -1,9 +1,10 @@ +// @ts-nocheck /** * Unit Tests for Webhook Integration Module * * Tests for util/webhook.js Discord webhook integration. * - * @see util/webhook.js + * @see src/util/webhook.js * @see docs/ARCHITECT_PLAN.md - Phase 4, Task 4.5 */ @@ -24,7 +25,7 @@ describe('webhook module', () => { createTestLogsDir(); // Fresh import - const module = await import('../../util/webhook.js'); + const module = await import('../../src/util/webhook.js'); webhook = module.default; // Reset rate limit state for each test module.resetRateLimitState(); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..28a8fda --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3558fda --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noEmit": false, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "types": ["bun-types"] + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/util/focus-detect.js b/util/focus-detect.js deleted file mode 100644 index 76e0aa3..0000000 --- a/util/focus-detect.js +++ /dev/null @@ -1,372 +0,0 @@ -import os from 'os'; -import path from 'path'; -import fs from 'fs'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import detectTerminal from 'detect-terminal'; - -/** - * Focus Detection Module for OpenCode Smart Voice Notify - * - * Detects whether the user is currently looking at the OpenCode terminal. - * Used to suppress notifications when the user is already focused on the terminal. - * - * Platform support: - * - macOS: Full support using AppleScript to check frontmost app - * - Windows: Not supported (returns false - no reliable API) - * - Linux: Not supported (returns false - varies by desktop environment) - * - * @module util/focus-detect - * @see docs/ARCHITECT_PLAN.md - Phase 3, Task 3.2 - */ - -const execAsync = promisify(exec); - -// ======================================== -// CACHING CONFIGURATION -// ======================================== - -/** - * Cache for focus detection results. - * Prevents excessive system calls (AppleScript execution). - */ -let focusCache = { - isFocused: false, - timestamp: 0, - terminalName: null -}; - -/** - * Cache TTL in milliseconds. - * Focus detection results are cached for this duration. - * 500ms provides a good balance between responsiveness and performance. - */ -const CACHE_TTL_MS = 500; - -/** - * List of known terminal application names for macOS. - * These are matched against the frontmost application name. - * The detect-terminal package helps identify which terminal is in use. - */ -export const KNOWN_TERMINALS_MACOS = [ - 'Terminal', - 'iTerm', - 'iTerm2', - 'Hyper', - 'Alacritty', - 'kitty', - 'WezTerm', - 'Tabby', - 'Warp', - 'Rio', - 'Ghostty', - // VS Code and other IDEs with integrated terminals - 'Code', - 'Visual Studio Code', - 'VSCodium', - 'Cursor', - 'Windsurf', - 'Zed', - // JetBrains IDEs - 'IntelliJ IDEA', - 'WebStorm', - 'PyCharm', - 'PhpStorm', - 'GoLand', - 'RubyMine', - 'CLion', - 'DataGrip', - 'Rider', - 'Android Studio' -]; - -// ======================================== -// DEBUG LOGGING -// ======================================== - -/** - * Debug logging to file. - * Only logs when enabled. - * Writes to ~/.config/opencode/logs/smart-voice-notify-debug.log - * - * @param {string} message - Message to log - * @param {boolean} enabled - Whether debug logging is enabled - */ -const debugLog = (message, enabled = false) => { - if (!enabled) return; - - try { - const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode'); - const logsDir = path.join(configDir, 'logs'); - - if (!fs.existsSync(logsDir)) { - fs.mkdirSync(logsDir, { recursive: true }); - } - - const logFile = path.join(logsDir, 'smart-voice-notify-debug.log'); - const timestamp = new Date().toISOString(); - fs.appendFileSync(logFile, `[${timestamp}] [focus-detect] ${message}\n`); - } catch (e) { - // Silently fail - logging should never break the plugin - } -}; - -// ======================================== -// PLATFORM DETECTION -// ======================================== - -/** - * Get the current platform identifier. - * @returns {'darwin' | 'win32' | 'linux'} Platform string - */ -export const getPlatform = () => os.platform(); - -/** - * Check if focus detection is supported on this platform. - * - * @returns {{ supported: boolean, reason?: string }} Support status - */ -export const isFocusDetectionSupported = () => { - const platform = getPlatform(); - - switch (platform) { - case 'darwin': - return { supported: true }; - case 'win32': - return { supported: false, reason: 'Windows focus detection not supported - no reliable API' }; - case 'linux': - return { supported: false, reason: 'Linux focus detection not supported - varies by desktop environment' }; - default: - return { supported: false, reason: `Unsupported platform: ${platform}` }; - } -}; - -// ======================================== -// TERMINAL DETECTION -// ======================================== - -/** - * Detect the current terminal emulator using detect-terminal package. - * Caches the result since the terminal doesn't change during execution. - * - * @param {boolean} debug - Enable debug logging - * @returns {string | null} Terminal name or null if not detected - */ -let cachedTerminalName = null; -let terminalDetectionAttempted = false; - -export const getTerminalName = (debug = false) => { - // Return cached result if already detected - if (terminalDetectionAttempted) { - return cachedTerminalName; - } - - try { - terminalDetectionAttempted = true; - // Prefer the outer terminal (GUI app) over multiplexers like tmux/screen - const terminal = detectTerminal({ preferOuter: true }); - cachedTerminalName = terminal || null; - debugLog(`Detected terminal: ${cachedTerminalName}`, debug); - return cachedTerminalName; - } catch (e) { - debugLog(`Terminal detection failed: ${e.message}`, debug); - return null; - } -}; - -// ======================================== -// FOCUS DETECTION - macOS -// ======================================== - -/** - * AppleScript to get the frontmost application name. - * Uses System Events to determine which app is currently focused. - */ -const APPLESCRIPT_GET_FRONTMOST = ` -tell application "System Events" - set frontApp to first application process whose frontmost is true - return name of frontApp -end tell -`; - -/** - * Get the name of the frontmost application on macOS. - * - * @param {boolean} debug - Enable debug logging - * @returns {Promise} Frontmost app name or null on error - */ -const getFrontmostAppMacOS = async (debug = false) => { - try { - const { stdout } = await execAsync(`osascript -e '${APPLESCRIPT_GET_FRONTMOST}'`, { - timeout: 2000, // 2 second timeout - maxBuffer: 1024 // Small buffer - we only expect app name - }); - - const appName = stdout.trim(); - debugLog(`Frontmost app: "${appName}"`, debug); - return appName; - } catch (e) { - debugLog(`Failed to get frontmost app: ${e.message}`, debug); - return null; - } -}; - -/** - * Check if the frontmost app is a known terminal on macOS. - * - * @param {string} appName - The frontmost application name - * @param {boolean} debug - Enable debug logging - * @returns {boolean} True if the app is a known terminal - */ -const isKnownTerminal = (appName, debug = false) => { - if (!appName) return false; - - // Direct match - if (KNOWN_TERMINALS_MACOS.some(t => t.toLowerCase() === appName.toLowerCase())) { - debugLog(`"${appName}" is a known terminal (direct match)`, debug); - return true; - } - - // Partial match (for apps like "iTerm2" matching "iTerm") - if (KNOWN_TERMINALS_MACOS.some(t => appName.toLowerCase().includes(t.toLowerCase()))) { - debugLog(`"${appName}" is a known terminal (partial match)`, debug); - return true; - } - - // Check if the detected terminal from detect-terminal matches - const detectedTerminal = getTerminalName(debug); - if (detectedTerminal && appName.toLowerCase().includes(detectedTerminal.toLowerCase())) { - debugLog(`"${appName}" matches detected terminal "${detectedTerminal}"`, debug); - return true; - } - - debugLog(`"${appName}" is NOT a known terminal`, debug); - return false; -}; - -// ======================================== -// MAIN FOCUS DETECTION FUNCTION -// ======================================== - -/** - * Check if the OpenCode terminal is currently focused. - * - * This function detects whether the user is currently looking at the terminal - * where OpenCode is running. Used to suppress notifications when the user - * is already paying attention to the terminal. - * - * Platform behavior: - * - macOS: Uses AppleScript to check the frontmost application - * - Windows: Always returns false (not supported) - * - Linux: Always returns false (not supported) - * - * Results are cached for 500ms to avoid excessive system calls. - * - * @param {object} [options={}] - Options - * @param {boolean} [options.debugLog=false] - Enable debug logging - * @returns {Promise} True if terminal is focused, false otherwise - * - * @example - * const focused = await isTerminalFocused({ debugLog: true }); - * if (focused) { - * console.log('User is looking at the terminal - skip notification'); - * } - */ -export const isTerminalFocused = async (options = {}) => { - const debug = options?.debugLog || false; - const now = Date.now(); - - // Check cache first - if (now - focusCache.timestamp < CACHE_TTL_MS) { - debugLog(`Using cached focus result: ${focusCache.isFocused}`, debug); - return focusCache.isFocused; - } - - const platform = getPlatform(); - - // Platform-specific implementation - if (platform === 'darwin') { - try { - const frontmostApp = await getFrontmostAppMacOS(debug); - const isFocused = isKnownTerminal(frontmostApp, debug); - - // Update cache - focusCache = { - isFocused, - timestamp: now, - terminalName: frontmostApp - }; - - debugLog(`Focus detection complete: ${isFocused} (frontmost: "${frontmostApp}")`, debug); - return isFocused; - } catch (e) { - debugLog(`Focus detection error: ${e.message}`, debug); - // On error, assume not focused (fail open - still notify) - focusCache = { - isFocused: false, - timestamp: now, - terminalName: null - }; - return false; - } - } - - // Windows and Linux: Not supported - if (platform === 'win32') { - debugLog('Focus detection not supported on Windows', debug); - } else if (platform === 'linux') { - debugLog('Focus detection not supported on Linux', debug); - } else { - debugLog(`Focus detection not supported on platform: ${platform}`, debug); - } - - // Cache the result even for unsupported platforms - focusCache = { - isFocused: false, - timestamp: now, - terminalName: null - }; - - return false; -}; - -/** - * Clear the focus detection cache. - * Useful for testing or when forcing a fresh check. - */ -export const clearFocusCache = () => { - focusCache = { - isFocused: false, - timestamp: 0, - terminalName: null - }; -}; - -/** - * Reset the terminal detection cache. - * Useful for testing. - */ -export const resetTerminalDetection = () => { - cachedTerminalName = null; - terminalDetectionAttempted = false; -}; - -/** - * Get the current cache state. - * Useful for testing and debugging. - * - * @returns {{ isFocused: boolean, timestamp: number, terminalName: string | null }} Cache state - */ -export const getCacheState = () => ({ ...focusCache }); - -// Default export for convenience -export default { - isTerminalFocused, - isFocusDetectionSupported, - getTerminalName, - getPlatform, - clearFocusCache, - resetTerminalDetection, - getCacheState, - KNOWN_TERMINALS_MACOS -};