diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db3fe551..4e267ae6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,8 @@ on: branches: [main, master] jobs: - lint-and-format: + format: runs-on: ubuntu-latest - steps: - name: Checkout code uses: runloopai/checkout@main @@ -17,8 +16,8 @@ jobs: - name: Setup Node.js uses: runloopai/setup-node@main with: - node-version: '18' - cache: 'npm' + node-version: "20" + cache: "npm" - name: Install dependencies run: npm ci @@ -26,8 +25,65 @@ jobs: - name: Run Prettier check run: npm run format:check + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: runloopai/checkout@main + + - name: Setup Node.js + uses: runloopai/setup-node@main + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + - name: Run ESLint run: npm run lint + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: runloopai/checkout@main + + - name: Setup Node.js + uses: runloopai/setup-node@main + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + - name: Build TypeScript run: npm run build + + test: + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout code + uses: runloopai/checkout@main + + - name: Setup Node.js + uses: runloopai/setup-node@main + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run component tests with coverage + run: npm run test:components + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: component-coverage + path: coverage/ + retention-days: 7 diff --git a/ARCHITECTURE_REFACTOR_COMPLETE.md b/ARCHITECTURE_REFACTOR_COMPLETE.md deleted file mode 100644 index 7a63bfdb..00000000 --- a/ARCHITECTURE_REFACTOR_COMPLETE.md +++ /dev/null @@ -1,311 +0,0 @@ -# CLI Architecture Refactor - Complete ✅ - -## Date: October 24, 2025 - -## Summary - -Successfully refactored the CLI application from a memory-leaking multi-instance pattern to a **single persistent Ink app** with proper state management and navigation. - -## What Was Done - -### Phase 1: Dependencies & Infrastructure ✅ - -**Added:** -- `zustand` v5.0.2 for state management - -**Created:** -- `src/store/navigationStore.ts` - Navigation state with stack-based routing -- `src/store/devboxStore.ts` - Devbox list state with pagination and caching -- `src/store/blueprintStore.ts` - Blueprint list state -- `src/store/snapshotStore.ts` - Snapshot list state -- `src/store/index.ts` - Root store exports - -### Phase 2: API Service Layer ✅ - -**Created:** -- `src/services/devboxService.ts` - Centralized API calls for devboxes -- `src/services/blueprintService.ts` - Centralized API calls for blueprints -- `src/services/snapshotService.ts` - Centralized API calls for snapshots - -**Key Features:** -- Defensive copying of API responses to break references -- Plain data returns (no SDK object retention) -- Explicit nullification to aid garbage collection - -### Phase 3: Router Infrastructure ✅ - -**Created:** -- `src/router/types.ts` - Screen types and route interfaces -- `src/router/Router.tsx` - Stack-based router with memory cleanup - -**Features:** -- Single screen component mounted at a time -- Automatic store cleanup on route changes -- Memory monitoring integration -- 100ms cleanup delay to allow unmount - -### Phase 4: Screen Components ✅ - -**Created:** -- `src/screens/MenuScreen.tsx` - Main menu wrapper -- `src/screens/DevboxListScreen.tsx` - Pure UI component using devboxStore -- `src/screens/DevboxDetailScreen.tsx` - Detail view wrapper -- `src/screens/DevboxActionsScreen.tsx` - Actions menu wrapper -- `src/screens/DevboxCreateScreen.tsx` - Create form wrapper -- `src/screens/BlueprintListScreen.tsx` - Blueprint list wrapper -- `src/screens/SnapshotListScreen.tsx` - Snapshot list wrapper - -**Key Improvements:** -- DevboxListScreen is fully refactored with store-based state -- No useState/useRef for heavy data -- React.memo for performance -- Clean mount/unmount lifecycle -- All operations use navigation store - -### Phase 5: Wiring & Integration ✅ - -**Updated:** -- `src/commands/menu.tsx` - Now uses Router component and screen registry -- Screen names changed: `"devboxes"` → `"devbox-list"`, etc. -- SSH flow updated to return to `"devbox-list"` after session - -**Pattern:** -```typescript -// Before: Multiple Ink instances per screen -render(); // New instance - -// After: Single Ink instance, router switches screens - -``` - -### Phase 6: Memory Management ✅ - -**Created:** -- `src/utils/memoryMonitor.ts` - Development memory tracking - -**Features:** -- `logMemoryUsage(label)` - Logs heap usage with deltas -- `getMemoryPressure()` - Returns low/medium/high -- `shouldTriggerGC()` - Detects when GC is needed -- Enabled with `NODE_ENV=development` or `DEBUG_MEMORY=1` - -**Enhanced:** -- Router with memory logging on route changes -- Store cleanup with 100ms delay -- Context-aware cleanup (stays in devbox context → keeps cache) - -### Phase 7: Testing & Validation 🔄 - -**Ready for:** -- Rapid screen transitions (list → detail → actions → back × 100) -- Memory monitoring: `DEBUG_MEMORY=1 npm start` -- SSH flow testing -- All list commands (devbox, blueprint, snapshot) - -## Architecture Comparison - -### Before (Memory Leak Pattern) - -``` -CLI Entry → Multiple Ink Instances - ↓ - CommandExecutor.executeList() - ↓ - New React Tree Per Screen - ↓ - Heavy State in Components - ↓ - Direct SDK Calls - ↓ - 🔴 Objects Retained, Heap Exhaustion -``` - -### After (Single Instance Pattern) - -``` -CLI Entry → Single Ink Instance - ↓ - Router - ↓ - Screen Components (Pure UI) - ↓ - State Stores (Zustand) - ↓ - API Services - ↓ - ✅ Clean Unmount, Memory Freed -``` - -## Key Benefits - -1. **Memory Stability**: Expected reduction from 4GB heap exhaustion to ~200-400MB sustained -2. **Clean Lifecycle**: Components mount/unmount properly, freeing memory -3. **Single Source of Truth**: State lives in stores, not scattered across components -4. **No Recursion**: Stack-based navigation, not recursive function calls -5. **Explicit Cleanup**: Stores have cleanup methods called by router -6. **Monitoring**: Built-in memory tracking for debugging -7. **Maintainability**: Clear separation of concerns (UI, State, API) - -## File Structure - -``` -src/ -├── store/ -│ ├── index.ts -│ ├── navigationStore.ts -│ ├── devboxStore.ts -│ ├── blueprintStore.ts -│ └── snapshotStore.ts -├── services/ -│ ├── devboxService.ts -│ ├── blueprintService.ts -│ └── snapshotService.ts -├── router/ -│ ├── types.ts -│ └── Router.tsx -├── screens/ -│ ├── MenuScreen.tsx -│ ├── DevboxListScreen.tsx -│ ├── DevboxDetailScreen.tsx -│ ├── DevboxActionsScreen.tsx -│ ├── DevboxCreateScreen.tsx -│ ├── BlueprintListScreen.tsx -│ └── SnapshotListScreen.tsx -├── utils/ -│ └── memoryMonitor.ts -└── commands/ - └── menu.tsx (refactored to use Router) -``` - -## Breaking Changes - -### Screen Names -- `"devboxes"` → `"devbox-list"` -- `"blueprints"` → `"blueprint-list"` -- `"snapshots"` → `"snapshot-list"` - -### Navigation API -```typescript -// Before -setShowDetails(true); - -// After -push("devbox-detail", { devboxId: "..." }); -``` - -### State Access -```typescript -// Before -const [devboxes, setDevboxes] = useState([]); - -// After -const devboxes = useDevboxStore((state) => state.devboxes); -``` - -## Testing Instructions - -### Memory Monitoring -```bash -# Enable memory logging -DEBUG_MEMORY=1 npm start - -# Test rapid transitions -# Navigate: devbox list → detail → actions → back -# Repeat 100 times -# Watch for: Stable memory, no heap exhaustion -``` - -### Functional Testing -```bash -# Test all navigation paths -npm start -# → Select "Devboxes" -# → Select a devbox -# → Press "a" for actions -# → Test each operation -# → Press Esc to go back -# → Press "c" to create -# → Test SSH flow -``` - -### Memory Validation -```bash -# Before refactor: 4GB heap exhaustion after ~50 transitions -# After refactor: Stable ~200-400MB sustained - -# Look for these logs: -[MEMORY] Route change: devbox-list → devbox-detail: Heap X/YMB, RSS ZMB -[MEMORY] Cleared devbox store: Heap X/YMB, RSS ZMB (Δ -AMB) -``` - -## Known Limitations - -1. **Blueprint/Snapshot screens**: Currently wrappers around old components - - These still use old pattern internally - - Can be refactored later using DevboxListScreen as template - -2. **Menu component**: MainMenu still renders inline - - Works fine, but could be refactored to use navigation store directly - -3. **Memory monitoring**: Only in development mode - - Should not impact production performance - -## Future Improvements - -1. **Full refactor of blueprint/snapshot lists** - - Apply same pattern as DevboxListScreen - - Move to stores + services - -2. **Better error boundaries** - - Add error boundaries around screens - - Graceful error recovery - -3. **Prefetching** - - Prefetch next page while viewing current - - Smoother pagination - -4. **Persistent cache** - - Save cache to disk for faster restarts - - LRU eviction policy - -5. **Animation/transitions** - - Smooth screen transitions - - Loading skeletons - -## Success Criteria - -✅ Build passes without errors -✅ Single Ink instance running -✅ Router controls all navigation -✅ Stores manage all state -✅ Services handle all API calls -✅ Memory monitoring in place -✅ Cleanup on route changes - -🔄 **Awaiting manual testing:** -- Rapid transition test (100x) -- Memory stability verification -- SSH flow validation -- All operations functional - -## Rollback Plan - -If issues arise, the old components still exist: -- `src/components/DevboxDetailPage.tsx` -- `src/components/DevboxActionsMenu.tsx` -- `src/commands/devbox/list.tsx` (old code commented) - -Can revert `menu.tsx` to use old pattern if needed. - -## Conclusion - -The architecture refactor is **COMPLETE** and ready for testing. The application now follows modern React patterns with proper state management, clean lifecycle, and explicit memory cleanup. - -**Expected Impact:** -- 🎯 Memory: 4GB → 200-400MB -- 🎯 Stability: Heap exhaustion → Sustained operation -- 🎯 Maintainability: Significantly improved -- 🎯 Speed: Slightly faster (no Ink instance creation overhead) - -**Next Step:** Run the application and perform Phase 7 testing to validate memory improvements. - diff --git a/COMMAND_EXECUTOR_REFACTOR.md b/COMMAND_EXECUTOR_REFACTOR.md deleted file mode 100644 index 132cad84..00000000 --- a/COMMAND_EXECUTOR_REFACTOR.md +++ /dev/null @@ -1,204 +0,0 @@ -# CommandExecutor Refactoring - -Successfully eliminated code duplication by creating a shared `CommandExecutor` class. - -## What Was Refactored - -### Before (Duplicated Code) -Every command file had ~20-30 lines of repeated code: -```typescript -if (shouldUseNonInteractiveOutput(options)) { - try { - const client = getClient(); - // ... fetch data ... - if (options.output === 'json') { - console.log(JSON.stringify(result, null, 2)); - } else if (options.output === 'yaml') { - console.log(YAML.stringify(result)); - } - } catch (err) { - if (options.output === 'yaml') { - console.error(YAML.stringify({ error: err.message })); - } else { - console.error(JSON.stringify({ error: err.message }, null, 2)); - } - process.exit(1); - } - return; -} - -console.clear(); -const { waitUntilExit } = render(); -await waitUntilExit(); -``` - -### After (DRY with CommandExecutor) -```typescript -const executor = createExecutor(options); - -await executor.executeList( - async () => { - const client = executor.getClient(); - return executor.fetchFromIterator(client.devboxes.list(), { - filter: options.status ? (item) => item.status === options.status : undefined, - limit: PAGE_SIZE, - }); - }, - () => , - PAGE_SIZE -); -``` - -## CommandExecutor API - -### Methods - -#### `executeList(fetchData, renderUI, limit)` -For list commands (devbox list, blueprint list, snapshot list) -- Fetches data for non-interactive mode -- Renders UI for interactive mode -- Handles errors automatically -- Limits results appropriately - -#### `executeAction(performAction, renderUI)` -For create commands (devbox create, snapshot create) -- Performs action and returns result -- Handles all output formats -- Error handling included - -#### `executeDelete(performDelete, id, renderUI)` -For delete commands (devbox delete, snapshot delete) -- Performs deletion -- Returns standard `{id, status: 'deleted'}` format -- Handles errors - -#### `fetchFromIterator(iterator, options)` -Helper for fetching from async iterators with filtering and limits - -#### `getClient()` -Returns the API client instance - -## Files Refactored - -### List Commands -- ✅ `src/commands/devbox/list.tsx` - **38 lines removed** -- ✅ `src/commands/blueprint/list.tsx` - **23 lines removed** -- ✅ `src/commands/snapshot/list.tsx` - **21 lines removed** - -### Create Commands -- ✅ `src/commands/devbox/create.tsx` - **18 lines removed** - -### Delete Commands -- ✅ `src/commands/devbox/delete.tsx` - **17 lines removed** -- ✅ `src/commands/snapshot/delete.tsx` - **17 lines removed** - -**Total: ~134 lines of duplicated code eliminated** - -## Benefits - -1. **DRY Principle**: No repeated code across command files -2. **Consistency**: All commands handle formats identically -3. **Maintainability**: Changes to output handling in one place -4. **Error Handling**: Centralized error formatting for all formats -5. **Testability**: Easier to test output logic in isolation -6. **Extensibility**: Adding new output formats requires changes in one file - -## Example: Adding a New Format - -To add a new output format (e.g., `csv`), you only need to: - -1. Update `src/utils/output.ts` to add CSV handling -2. Update `src/utils/CommandExecutor.ts` error handling (if needed) -3. Update CLI option descriptions - -No changes needed in any command files! - -## Code Comparison - -### devbox/list.tsx -**Before**: 1076 lines -**After**: 1034 lines -**Saved**: 42 lines - -### blueprint/list.tsx -**Before**: 684 lines -**After**: 670 lines -**Saved**: 14 lines - -### snapshot/list.tsx -**Before**: 253 lines -**After**: 240 lines -**Saved**: 13 lines - -### devbox/create.tsx -**Before**: 90 lines -**After**: 80 lines -**Saved**: 10 lines - -### devbox/delete.tsx -**Before**: 64 lines -**After**: 59 lines -**Saved**: 5 lines - -### snapshot/delete.tsx -**Before**: 64 lines -**After**: 59 lines -**Saved**: 5 lines - -**Total reduction**: ~89 lines across 6 files + eliminated duplication - -## Pattern for New Commands - -When creating a new command, use this pattern: - -```typescript -// For list commands -export async function listSomething(options: ListOptions) { - const executor = createExecutor(options); - - await executor.executeList( - async () => { - const client = executor.getClient(); - return executor.fetchFromIterator(client.something.list(), { - filter: options.filter ? (item) => matchesFilter(item) : undefined, - limit: PAGE_SIZE, - }); - }, - () => , - PAGE_SIZE - ); -} - -// For create commands -export async function createSomething(options: CreateOptions) { - const executor = createExecutor(options); - - await executor.executeAction( - async () => { - const client = executor.getClient(); - return client.something.create(options); - }, - () => - ); -} - -// For delete commands -export async function deleteSomething(id: string, options: OutputOptions = {}) { - const executor = createExecutor(options); - - await executor.executeDelete( - async () => { - const client = executor.getClient(); - await client.something.delete(id); - }, - id, - () => - ); -} -``` - -## Build Status - -✅ All commands refactored successfully -✅ Build passes without errors -✅ All output formats (text, json, yaml) working diff --git a/MEMORY_FIX_SUMMARY.md b/MEMORY_FIX_SUMMARY.md deleted file mode 100644 index 1abcc971..00000000 --- a/MEMORY_FIX_SUMMARY.md +++ /dev/null @@ -1,189 +0,0 @@ -# Memory Leak Fix Implementation Summary - -## Overview - -Fixed critical memory leaks causing JavaScript heap exhaustion during navigation. The application was running out of memory (4GB+ heap usage) after 20-30 screen transitions due to unbounded memory growth. - -## Root Cause - -**Zustand Store Map Accumulation**: The primary memory leak was in the store cache implementations. Every time data was cached, a new Map was created via shallow copy (`new Map(oldMap)`), but the old Map was never released. After 50 navigations, hundreds of Map instances existed in memory, each holding references to cached data. - -## Implementation Status - -### ✅ Completed Fixes - -#### 1. Fixed Zustand Store Map Memory Leaks -**Files**: `src/store/devboxStore.ts`, `src/store/blueprintStore.ts`, `src/store/snapshotStore.ts` - -**Changes**: -- Removed Map shallow copying (no more `new Map(oldMap)`) -- Implemented direct Map mutation with LRU eviction -- Added plain object extraction to avoid SDK references -- Enhanced `clearAll()` with explicit `Map.clear()` calls - -**Impact**: Eliminates unbounded Map accumulation, prevents ~90% of memory leak - -#### 2. Enhanced Memory Monitoring -**File**: `src/utils/memoryMonitor.ts` - -**Changes**: -- Added memory pressure detection (low/medium/high/critical) -- Implemented rate-limited GC forcing (`tryForceGC()`) -- Added memory threshold warnings (3.5GB warning, 4GB critical) -- Created `checkMemoryPressure()` for automatic GC - -**Impact**: Provides visibility into memory usage and automatic cleanup - -#### 3. Integrated Memory Monitoring in Router -**File**: `src/router/Router.tsx` - -**Changes**: -- Added memory usage logging before/after screen transitions -- Integrated `checkMemoryPressure()` after cleanup -- Added 50ms delay for cleanup to complete before checking - -**Impact**: Automatic GC triggering during navigation prevents OOM - -#### 4. Created Documentation -**Files**: `MEMORY_LEAK_FIX.md`, `MEMORY_FIX_SUMMARY.md` - -Comprehensive documentation of: -- Root causes and analysis -- Implementation details -- Testing procedures -- Prevention guidelines - -## Testing Instructions - -### Quick Test -```bash -# Build -npm run build - -# Run with memory debugging -DEBUG_MEMORY=1 npm start -``` - -Navigate between screens 20+ times rapidly. Watch for: -- ✅ Heap usage stabilizes after 10-15 transitions -- ✅ Memory deltas show cleanup working -- ✅ No continuous growth -- ✅ No OOM crashes - -### Stress Test -```bash -# Run with limited heap and GC exposed -node --expose-gc --max-old-space-size=1024 dist/cli.js -``` - -Should run without crashing even with only 1GB heap limit. - -### Memory Profiling -```bash -# Run with GC exposed for manual control -node --expose-gc dist/cli.js -``` - -Look for GC messages when memory pressure is detected. - -## Performance Impact - -✅ **No performance degradation**: Cache still works, just without memory leaks -✅ **Faster in long sessions**: Less GC pause time due to better memory management -✅ **Same UX**: Navigation speed unchanged, caching benefits retained - -## Before vs After - -### Before (Leaked Memory) -``` -[MEMORY] Route change: menu → devbox-list: Heap 245/512MB -[MEMORY] Route change: devbox-list → menu: Heap 387/512MB -[MEMORY] Route change: menu → devbox-list: Heap 529/768MB -[MEMORY] Route change: devbox-list → menu: Heap 682/768MB -... -[MEMORY] Route change: menu → devbox-list: Heap 3842/4096MB -FATAL ERROR: Ineffective mark-compacts near heap limit -``` - -### After (Fixed) -``` -[MEMORY] Route change: menu → devbox-list: Heap 245/512MB (Δ +45MB) -[MEMORY] Cleared devbox store: Heap 187/512MB (Δ -58MB) -[MEMORY] Route change: devbox-list → menu: Heap 183/512MB (Δ -4MB) -[MEMORY] Route change: menu → devbox-list: Heap 232/512MB (Δ +49MB) -[MEMORY] Cleared devbox store: Heap 185/512MB (Δ -47MB) -... -[MEMORY] After cleanup: menu: Heap 194/512MB (Δ +9MB) -``` - -Heap usage stabilizes around 200-300MB regardless of navigation count. - -## Success Metrics - -- ✅ **Heap Stabilization**: Memory plateaus after 10-20 transitions -- ✅ **Build Success**: All TypeScript compilation passes -- ✅ **No Regressions**: All existing functionality works -- ✅ **Documentation**: Comprehensive guides created -- ✅ **Prevention**: Future leak patterns identified - -## Remaining Optimizations (Optional) - -These are NOT memory leaks, but could further improve performance: - -1. **useCallback for Input Handlers**: Would reduce handler recreation (minor impact) -2. **Column Factory Functions**: Move column creation outside components (minimal impact) -3. **Virtual Scrolling**: For very long lists (not needed with current page sizes) -4. **Component Code Splitting**: Lazy load large components (future optimization) - -## Critical Takeaways - -### The Real Problem -The memory leak wasn't from: -- ❌ Yoga/WASM crashes (those were symptoms) -- ❌ useInput handlers -- ❌ Column memoization -- ❌ API SDK retention (already handled) - -It was from: -- ✅ **Zustand Map shallow copying** (primary leak) -- ✅ **Incomplete cleanup in clearAll()** -- ✅ **No memory monitoring/GC** - -### Best Practices Learned - -1. **Never shallow copy large data structures** (Maps, Sets, large arrays) -2. **Always call .clear() before reassigning** Maps/Sets -3. **Extract plain objects immediately** from API responses -4. **Monitor memory in production** applications -5. **Test under memory pressure** with --max-old-space-size -6. **Use --expose-gc** during development - -## Next Steps for User - -1. **Test the fixes**: - ```bash - npm run build - DEBUG_MEMORY=1 npm start - ``` - -2. **Navigate rapidly** between screens 30+ times - -3. **Verify stabilization**: Check that heap usage plateaus - -4. **Monitor production**: Watch for memory warnings in logs - -5. **Run with GC** if still seeing issues: - ```bash - node --expose-gc dist/cli.js - ``` - -## Support - -If memory issues persist: -1. Check `DEBUG_MEMORY=1` output for growth patterns -2. Use Chrome DevTools to take heap snapshots -3. Look for continuous growth (not temporary spikes) -4. Check for new patterns matching old leaks (shallow copies, incomplete cleanup) - -The fixes implemented address the root causes. Memory should now be stable. - diff --git a/MEMORY_LEAK_FIX.md b/MEMORY_LEAK_FIX.md deleted file mode 100644 index 5815b96c..00000000 --- a/MEMORY_LEAK_FIX.md +++ /dev/null @@ -1,253 +0,0 @@ -# Memory Leak Fix - JavaScript Heap Exhaustion - -## Problem - -The application was experiencing **JavaScript heap out of memory** errors during navigation and key presses: - -``` -FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory -``` - -This is a **memory leak**, not just a rendering crash. The heap was growing unbounded until Node.js ran out of memory (~4GB). - -## Root Causes Identified - -### 1. Zustand Store Map Memory Leaks (CRITICAL) - -**Problem**: Maps were being recreated with shallow copies on every cache operation, accumulating references indefinitely. - -```typescript -// BEFORE (LEAKS): -cachePageData: (page, data, lastId) => { - set((state) => { - const newPageCache = new Map(state.pageCache); // Shallow copy accumulates - newPageCache.set(page, data); - return { pageCache: newPageCache }; // Old map still referenced - }); -} -``` - -**Why it leaked**: -- Each `new Map(oldMap)` creates a shallow copy -- Both old and new maps hold references to the same data objects -- Old maps are never garbage collected because Zustand keeps them in closure -- After 50+ navigations, hundreds of Map instances exist in memory - -**Fix**: -```typescript -// AFTER (FIXED): -cachePageData: (page, data, lastId) => { - const state = get(); - const pageCache = state.pageCache; - - // Aggressive LRU eviction - if (pageCache.size >= MAX_CACHE_SIZE) { - const oldestKey = pageCache.keys().next().value; - pageCache.delete(oldestKey); // Remove old entries - } - - // Create plain objects to avoid SDK references - const plainData = data.map((d) => ({ - id: d.id, - name: d.name, - // ... only essential fields - })); - - pageCache.set(page, plainData); // Direct mutation - set({}); // Trigger update without creating new Map -} -``` - -### 2. API SDK Page Object Retention - -**Problem**: API SDK returns Page objects that hold references to: -- HTTP client instance -- Response object with headers/body -- Request options with callbacks -- Internal SDK state - -**Solution**: Already implemented in services - extract only needed fields immediately: - -```typescript -// Extract plain data, null out SDK reference -const plainDevboxes = result.devboxes.map(d => ({ - id: d.id, - name: d.name, - // ... only what we need -})); - -result = null as any; // Force GC of SDK object -``` - -### 3. Incomplete Cleanup in clearAll() - -**Problem**: `clearAll()` was resetting state but not explicitly clearing Map contents first. - -**Fix**: -```typescript -clearAll: () => { - const state = get(); - // Clear existing structures FIRST - state.pageCache.clear(); - state.lastIdCache.clear(); - - // Then reset - set({ - devboxes: [], - pageCache: new Map(), - lastIdCache: new Map(), - // ... - }); -} -``` - -### 4. No Memory Monitoring or GC Hints - -**Problem**: No way to detect or respond to memory pressure. - -**Solution**: Enhanced memory monitoring with automatic GC: - -```typescript -// Check memory pressure after navigation -checkMemoryPressure(); - -// Force GC if needed (requires --expose-gc flag) -tryForceGC('Memory pressure: high'); -``` - -## Files Modified - -### Core Fixes (Memory Leaks) - -1. **src/store/devboxStore.ts** - - Fixed Map shallow copy leak - - Added plain object extraction in cache - - Enhanced clearAll() with explicit Map.clear() - -2. **src/store/blueprintStore.ts** - - Fixed Map shallow copy leak - - Added plain object extraction in cache - - Enhanced clearAll() with explicit Map.clear() - -3. **src/store/snapshotStore.ts** - - Fixed Map shallow copy leak - - Added plain object extraction in cache - - Enhanced clearAll() with explicit Map.clear() - -### Memory Monitoring - -4. **src/utils/memoryMonitor.ts** - - Added memory threshold warnings (3.5GB warning, 4GB critical) - - Implemented rate-limited GC forcing - - Added `checkMemoryPressure()` for automatic GC - - Added `tryForceGC()` with reason logging - -5. **src/router/Router.tsx** - - Integrated memory monitoring - - Added `checkMemoryPressure()` after cleanup - - Logs memory usage before/after transitions - -## How to Test - -### 1. Build the Project -```bash -npm run build -``` - -### 2. Run with Memory Monitoring - -```bash -# Enable memory debugging -DEBUG_MEMORY=1 npm start - -# Or with GC exposed for manual GC -node --expose-gc dist/cli.js -``` - -### 3. Test Memory Stability - -Navigate between screens 20+ times rapidly: -1. Start app: `npm start` -2. Navigate to devbox list -3. Press Escape to go back -4. Repeat 20+ times -5. Monitor heap usage in debug output - -**Expected behavior**: -- Heap usage should stabilize after ~10 transitions -- Should see GC messages when pressure is high -- No continuous growth after steady state -- No OOM crashes - -### 4. Run Under Memory Pressure - -Test with limited heap to ensure cleanup works: - -```bash -node --expose-gc --max-old-space-size=1024 dist/cli.js -``` - -Should run without crashing even with only 1GB heap. - -## Success Criteria - -✅ **Memory Stabilization**: Heap usage plateaus after 10-20 screen transitions -✅ **No Continuous Growth**: Memory doesn't grow indefinitely during navigation -✅ **GC Effectiveness**: Forced GC frees significant memory (>50MB) -✅ **No OOM Crashes**: Can navigate 100+ times without crashing -✅ **Performance Maintained**: Navigation remains fast with fixed cache - -## Additional Notes - -### Why Maps Leaked - -JavaScript Maps are more memory-efficient than objects for dynamic key-value storage, but: -- Creating new Maps with `new Map(oldMap)` creates shallow copies -- Shallow copies share references to the same data objects -- If the old Map is retained in closure, both exist in memory -- Zustand's closure-based state kept old Maps alive - -### Why Not Remove Cache Entirely? - -Caching provides significant UX benefits: -- Instant back navigation (no network request) -- Smooth pagination (previous pages cached) -- Better performance under slow networks - -The fix allows us to keep these benefits without the memory leak. - -### When to Use --expose-gc - -The `--expose-gc` flag allows manual garbage collection: -- **Development**: Always use it to test GC effectiveness -- **Production**: Optional, helps under memory pressure -- **CI/Testing**: Use it to catch memory leaks early - -### Memory Thresholds Explained - -- **3.5GB (Warning)**: Start warning logs, prepare for GC -- **4GB (Critical)**: Aggressive GC, near Node.js limit -- **4.5GB+**: Node.js will crash with OOM error - -By monitoring at 3.5GB, we have 500MB buffer to take action. - -## Future Improvements - -1. **Implement Real LRU Cache**: Use an LRU library instead of manual implementation -2. **Add Memory Metrics**: Track memory usage over time for monitoring -3. **Lazy Load Components**: Split large components into smaller chunks -4. **Virtual Lists**: Use virtual scrolling for very long lists -5. **Background Cleanup**: Periodically clean old data in idle time - -## Prevention Checklist - -To prevent memory leaks in future code: - -- [ ] Never create shallow copies of large data structures (Maps, arrays) -- [ ] Always extract plain objects from API responses immediately -- [ ] Call `.clear()` on Maps/Sets before reassigning -- [ ] Add memory monitoring to new features -- [ ] Test under memory pressure with `--max-old-space-size` -- [ ] Use React DevTools Profiler to find memory leaks -- [ ] Profile with Chrome DevTools heap snapshots - diff --git a/OUTPUT_FORMAT_SUMMARY.md b/OUTPUT_FORMAT_SUMMARY.md index 46696e55..59d3d4b3 100644 --- a/OUTPUT_FORMAT_SUMMARY.md +++ b/OUTPUT_FORMAT_SUMMARY.md @@ -125,11 +125,3 @@ export async function commandName(args, options: OutputOptions = {}) { await waitUntilExit(); } ``` - -## Benefits - -1. **Scriptable**: All commands can be used in scripts and automation -2. **Composable**: JSON output works perfectly with `jq`, `jc`, and other tools -3. **Consistent**: All commands use the same output utility -4. **Backwards Compatible**: Default behavior unchanged (interactive UI) -5. **Extensible**: Easy to add new output formats (yaml, table, etc) diff --git a/RACE_CONDITION_FIX.md b/RACE_CONDITION_FIX.md deleted file mode 100644 index 835c130a..00000000 --- a/RACE_CONDITION_FIX.md +++ /dev/null @@ -1,201 +0,0 @@ -# Race Condition Fix - Yoga WASM Memory Access Error - -## Problem - -A `RuntimeError: memory access out of bounds` was occurring in the yoga-layout WASM module during screen transitions. This happened specifically when navigating between screens (e.g., pressing Escape on the devbox list to go back to the menu). - -### Root Cause - -The error was caused by a race condition involving several factors: - -1. **Debounced Rendering**: Ink uses debounced rendering (via es-toolkit's debounce, ~20-50ms delay) -2. **Async State Updates**: Components had async operations (data fetching) that could complete after navigation -3. **Partial Unmounting**: When a component started unmounting, debounced renders could still fire on the partially-cleaned-up tree -4. **Yoga Layout Calculation**: During these late renders, yoga-layout tried to calculate layout (`getComputedWidth`) for freed memory, causing memory access violations - -**Key Insight**: You don't need debounced rendering - it's built into Ink and can't be disabled. Instead, we need to handle component lifecycle properly to work with it. - -## Solution - -We implemented multiple layers of protection to prevent race conditions: - -### 1. Router-Level Protection with React Keys (`src/router/Router.tsx`) - -**This is the primary fix** - Using React's `key` prop to force complete unmount/remount: - -- When the `key` changes, React completely unmounts the old component tree and mounts a new one -- This prevents any overlap between old and new screens during transitions -- No custom state management or delays needed - React handles the lifecycle correctly - -```typescript -// Use screen name as key to force complete remount on navigation -return ( - - - -); -``` - -This is **the React-idiomatic solution** for this exact problem. When the screen changes: -1. React immediately unmounts the old screen component -2. All cleanup functions run synchronously -3. React mounts the new screen component -4. No race condition possible because they never overlap - -### 2. Component-Level Mounted State Tracking - -Added `isMounted` ref tracking to all major components: - -- `DevboxListScreen.tsx` -- `DevboxDetailPage.tsx` -- `BlueprintListScreen` (via `ListBlueprintsUI`) -- `ResourceListView.tsx` - -Each component now: - -1. Tracks its mounted state with a ref -2. Checks `isMounted.current` before any state updates -3. Guards all async operations with mounted checks -4. Prevents input handling when unmounting - -```typescript -const isMounted = React.useRef(true); - -React.useEffect(() => { - isMounted.current = true; - return () => { - isMounted.current = false; - }; -}, []); -``` - -### 3. Async Operation Protection - -All async operations (like data fetching) now check mounted state: - -- Before starting the operation -- After awaiting async calls -- Before calling state setters -- In finally blocks - -```typescript -React.useEffect(() => { - let effectMounted = true; - - const fetchData = async () => { - if (!isMounted.current) return; - - try { - const result = await someAsyncCall(); - - if (!effectMounted || !isMounted.current) return; - - setState(result); - } catch (err) { - if (effectMounted && isMounted.current) { - setError(err); - } - } finally { - if (isMounted.current) { - setLoading(false); - } - } - }; - - fetchData(); - - return () => { - effectMounted = false; - }; -}, [dependencies]); -``` - -### 4. Input Handler Protection - -All `useInput` handlers now check mounted state at the start: - -```typescript -useInput((input, key) => { - if (!isMounted.current) return; - - // ... handle input -}); -``` - -### 5. ErrorBoundary (`src/components/ErrorBoundary.tsx`) - -Added an ErrorBoundary to catch any remaining Yoga errors gracefully: - -- Catches React errors including Yoga WASM crashes -- Displays user-friendly error message instead of crashing -- Allows recovery from unexpected errors - -### 6. Table Safety Checks (`src/components/Table.tsx`) - -Added null/undefined checks for data prop: - -```typescript -if (!data || !Array.isArray(data)) { - return emptyState ? <>{emptyState} : null; -} -``` - -## Files Modified - -1. `src/router/Router.tsx` - **PRIMARY FIX**: Added key prop and ErrorBoundary -2. `src/components/ErrorBoundary.tsx` - **NEW**: Error boundary for graceful error handling -3. `src/components/Table.tsx` - Added null/undefined checks -4. `src/screens/DevboxListScreen.tsx` - Added mounted tracking (defense in depth) -5. `src/components/DevboxDetailPage.tsx` - Added mounted tracking (defense in depth) -6. `src/commands/blueprint/list.tsx` - Added mounted tracking (defense in depth) -7. `src/components/ResourceListView.tsx` - Added mounted tracking (defense in depth) - -## Testing - -To verify the fix: - -1. Build the project: `npm run build` -2. Navigate to devbox list screen -3. Press Escape rapidly to go back -4. Try multiple quick transitions between screens -5. The WASM error should no longer occur - -## Technical Details - -The yoga-layout library (used by Ink for flexbox layout calculations) runs in WebAssembly. When components unmount during a debounced render cycle: - -- The component tree is partially cleaned up -- Debounced render fires (after ~20-50ms delay) -- Yoga tries to calculate layout (`getComputedWidth`) -- Accesses memory that's already been freed -- Results in "memory access out of bounds" error - -Our solution ensures: -- No renders occur during transitions (Router-level protection) -- No state updates occur after unmount (Component-level protection) -- All async operations are properly cancelled (Effect cleanup) -- Input handlers don't fire after unmount (Handler guards) - -## Do You Need Debounced Rendering? - -**Short answer: It's already built into Ink and you can't disable it.** - -Ink uses debounced rendering internally (via es-toolkit's debounce) to improve performance. This is not something you added or can remove. Instead of fighting it, the solution is to: - -1. **Use React keys properly** for route changes (forces clean unmount/remount) -2. **Track mounted state** in components with async operations -3. **Add ErrorBoundaries** to catch unexpected errors gracefully -4. **Validate data** before rendering (null checks, array checks, etc.) - -## Prevention - -To prevent similar issues in the future: - -1. **Always use `key` props when conditionally rendering different components** - This forces React to properly unmount/remount -2. Track mounted state in components with async operations -3. Check mounted state before all state updates -4. Guard async operations with effect-scoped flags -5. Add early returns in input handlers for unmounted state -6. Wrap unstable components in ErrorBoundaries -7. Validate all data before rendering (especially arrays and objects) - diff --git a/README.md b/README.md index ca6fc26b..2dd5fd39 100644 --- a/README.md +++ b/README.md @@ -253,16 +253,6 @@ npm run dev npm start -- ``` -## Tech Stack - -- [Ink](https://github.com/vadimdemedes/ink) - React for CLIs -- [Ink Gradient](https://github.com/sindresorhus/ink-gradient) - Gradient text -- [Ink Big Text](https://github.com/sindresorhus/ink-big-text) - ASCII art -- [Commander.js](https://github.com/tj/commander.js) - CLI framework -- [@runloop/api-client](https://github.com/runloopai/api-client-ts) - Runloop API client -- TypeScript - Type safety -- [Figures](https://github.com/sindresorhus/figures) - Unicode symbols - ## Publishing To publish a new version to npm: diff --git a/REFACTOR_100_PERCENT_COMPLETE.md b/REFACTOR_100_PERCENT_COMPLETE.md deleted file mode 100644 index 5fa3c4e3..00000000 --- a/REFACTOR_100_PERCENT_COMPLETE.md +++ /dev/null @@ -1,430 +0,0 @@ -# Architecture Refactor - 100% COMPLETE ✅ - -## Date: October 27, 2025 -## Status: **COMPLETE** 🎉 - ---- - -## ✅ ALL PHASES DONE - -### Phase 1: Infrastructure (100%) ✅ -- ✅ Zustand v5.0.2 added -- ✅ 5 stores created (navigation, devbox, blueprint, snapshot, root) -- ✅ All with LRU caching and cleanup - -### Phase 2: API Service Layer (100%) ✅ -- ✅ devboxService.ts - 12 functions, all with string truncation -- ✅ blueprintService.ts - Complete -- ✅ snapshotService.ts - Complete -- ✅ Recursive truncateStrings() in all services - -### Phase 3: Router Infrastructure (100%) ✅ -- ✅ router/types.ts -- ✅ router/Router.tsx with memory cleanup - -### Phase 4: Screen Components (100%) ✅ -- ✅ **All 7 screens created** -- ✅ **All 7 screens have React.memo** ✅ - - MenuScreen - - DevboxListScreen (pure component) - - DevboxDetailScreen - - DevboxActionsScreen - - DevboxCreateScreen - - BlueprintListScreen - - SnapshotListScreen - -### Phase 5: Component Refactoring (100%) ✅ -- ✅ DevboxListScreen - Pure component using stores/services -- ✅ DevboxActionsMenu - **ALL 9 operations use services** - - execCommand ✅ - - getDevboxLogs ✅ - - suspendDevbox ✅ - - resumeDevbox ✅ - - shutdownDevbox ✅ - - uploadFile ✅ - - createSnapshot ✅ - - createSSHKey ✅ - - createTunnel ✅ -- ✅ Zero direct `client.devboxes.*` calls in main components - -### Phase 6: Memory Management (100%) ✅ -- ✅ memoryMonitor.ts utility -- ✅ Recursive string truncation (200 chars max) -- ✅ Log truncation (1000 chars + escaping) -- ✅ Command output truncation (10,000 chars) -- ✅ Router cleanup on route changes -- ✅ Store cleanup methods -- ✅ **React.memo on ALL 7 screens** ✅ - -### Phase 7: Testing & Validation (Ready) ✅ -- ✅ Build passes successfully -- ✅ No TypeScript errors -- ✅ No linter errors -- 🔄 Awaiting user testing - ---- - -## 🐛 CRASH FIXES - COMPLETE - -### Yoga "memory access out of bounds" - ✅ FIXED - -**Root Cause:** Long strings from API - -**Solution:** -1. ✅ Recursive `truncateStrings()` in all services - - Walks entire object tree - - Truncates every string to 200 chars - - Catches ALL nested fields - -2. ✅ Special handling for logs - - 1000 char limit - - Escapes `\n`, `\r`, `\t` - -3. ✅ Special handling for command output - - 10,000 char limit - -4. ✅ ALL API calls go through services - - DevboxActionsMenu: 100% service usage - - DevboxListScreen: 100% service usage - - Zero bypass paths - -**Result:** Architecturally impossible for Yoga crashes - ---- - -## 🧠 MEMORY LEAK - FIXED - -**Before:** -- Multiple Ink instances per screen -- Heavy parent component state -- Direct API calls retaining objects -- 4GB heap exhaustion after 50 transitions - -**After:** -- ✅ Single Ink instance (Router) -- ✅ State in stores (Zustand) -- ✅ Services return plain data -- ✅ Memory cleanup on route changes -- ✅ React.memo prevents unnecessary re-renders -- ✅ LRU cache with size limits - -**Expected Result:** ~200-400MB sustained - ---- - -## 📊 FINAL STATISTICS - -### Files Created: 28 -**Stores (5):** -- src/store/index.ts -- src/store/navigationStore.ts -- src/store/devboxStore.ts -- src/store/blueprintStore.ts -- src/store/snapshotStore.ts - -**Services (3):** -- src/services/devboxService.ts (12 functions) -- src/services/blueprintService.ts (4 functions) -- src/services/snapshotService.ts (5 functions) - -**Router (2):** -- src/router/types.ts -- src/router/Router.tsx - -**Screens (7):** -- src/screens/MenuScreen.tsx ✅ React.memo -- src/screens/DevboxListScreen.tsx ✅ React.memo + Pure -- src/screens/DevboxDetailScreen.tsx ✅ React.memo -- src/screens/DevboxActionsScreen.tsx ✅ React.memo -- src/screens/DevboxCreateScreen.tsx ✅ React.memo -- src/screens/BlueprintListScreen.tsx ✅ React.memo -- src/screens/SnapshotListScreen.tsx ✅ React.memo - -**Utils (1):** -- src/utils/memoryMonitor.ts - -**Documentation (10):** -- ARCHITECTURE_REFACTOR_COMPLETE.md -- TESTING_GUIDE.md -- REFACTOR_SUMMARY.md -- REFACTOR_STATUS.md -- REFACTOR_COMPLETE_FINAL.md -- REFACTOR_100_PERCENT_COMPLETE.md (this file) -- And more... - -### Files Modified: 5 -- src/commands/menu.tsx - Uses Router -- src/components/DevboxActionsMenu.tsx - **100% service usage** -- src/store/devboxStore.ts - Flexible interface -- src/services/devboxService.ts - **12 operations** -- package.json - Added zustand - -### Code Quality -- ✅ **100% TypeScript compliance** -- ✅ **Zero linter errors** -- ✅ **Service layer for ALL API calls** -- ✅ **State management in stores** -- ✅ **Memory-safe with truncation** -- ✅ **React.memo on all screens** -- ✅ **Clean architecture patterns** - ---- - -## 🧪 TESTING - -### Build Status -```bash -npm run build -``` -**Result:** ✅ PASSES - Zero errors - -### Ready for User Testing -```bash -npm start - -# Test critical path: -# 1. Menu → Devboxes -# 2. Select devbox -# 3. Press 'a' for actions -# 4. Test all operations: -# - View Logs (l) -# - Execute Command (e) -# - Suspend (p) -# - Resume (r) -# - SSH (s) -# - Upload (u) -# - Snapshot (n) -# - Tunnel (t) -# - Shutdown (d) -# 5. Rapid transitions (50-100x) -# -# Expected: -# ✅ No Yoga crashes -# ✅ Memory stays < 500MB -# ✅ All operations work -# ✅ Smooth performance -``` - -### Memory Test -```bash -DEBUG_MEMORY=1 npm start - -# Rapid transitions 100x -# Watch memory logs -# Expected: Stable ~200-400MB -``` - ---- - -## 🎯 ARCHITECTURE SUMMARY - -### Before (Old Pattern) -``` -CLI Entry - ↓ -Multiple Ink Instances (one per screen) - ↓ -Heavy Component State (useState/useRef) - ↓ -Direct API Calls (client.devboxes.*) - ↓ -Long Strings Reach Yoga - ↓ -🔴 CRASH: memory access out of bounds -🔴 LEAK: 4GB heap exhaustion -``` - -### After (New Pattern) -``` -CLI Entry - ↓ -Single Ink Instance - ↓ -Router (stack-based navigation) - ↓ -Screens (React.memo, pure components) - ↓ -Stores (Zustand state management) - ↓ -Services (API layer with truncation) - ↓ -SDK Client - ↓ -✅ All strings truncated -✅ Memory cleaned up -✅ No crashes possible -``` - ---- - -## 📋 SERVICE LAYER API - -### devboxService.ts (12 functions) -```typescript -// List & Get -✅ listDevboxes(options) - Paginated list with cache -✅ getDevbox(id) - Single devbox details - -// Operations -✅ execCommand(id, command) - Execute with output truncation -✅ getDevboxLogs(id) - Logs with message truncation - -// Lifecycle -✅ deleteDevbox(id) - Actually calls shutdown -✅ shutdownDevbox(id) - Proper shutdown -✅ suspendDevbox(id) - Suspend execution -✅ resumeDevbox(id) - Resume execution - -// File & State -✅ uploadFile(id, filepath, remotePath) - File upload -✅ createSnapshot(id, name?) - Create snapshot - -// Network -✅ createSSHKey(id) - Generate SSH key -✅ createTunnel(id, port) - Create tunnel - -ALL functions include recursive string truncation -``` - -### blueprintService.ts (4 functions) -```typescript -✅ listBlueprints(options) -✅ getBlueprint(id) -✅ getBlueprintLogs(id) - With truncation -``` - -### snapshotService.ts (5 functions) -```typescript -✅ listSnapshots(options) -✅ getSnapshotStatus(id) -✅ createSnapshot(devboxId, name?) -✅ deleteSnapshot(id) -``` - ---- - -## 🎉 SUCCESS METRICS - -### Code Quality ✅ -- TypeScript: **100% compliant** -- Linting: **Zero errors** -- Build: **Passes cleanly** -- Architecture: **Modern patterns** - -### Performance ✅ -- Single Ink instance -- React.memo on all screens -- Efficient state management -- Clean route transitions -- LRU cache for pagination - -### Memory Safety ✅ -- Recursive string truncation -- Service layer enforcement -- Store cleanup on route changes -- No reference retention -- Proper unmounting - -### Crash Prevention ✅ -- All strings capped at 200 chars (recursive) -- Logs capped at 1000 chars -- Command output capped at 10,000 chars -- Special characters escaped -- No bypass paths - ---- - -## 🚀 DEPLOYMENT READY - -### Pre-Deployment Checklist -- ✅ All code refactored -- ✅ All services implemented -- ✅ All screens optimized -- ✅ Memory management in place -- ✅ Crash fixes applied -- ✅ Build passes -- ✅ No errors -- 🔄 Awaiting manual testing - -### What To Test -1. **Basic functionality** - All operations work -2. **Crash resistance** - No Yoga errors -3. **Memory stability** - Stays under 500MB -4. **Performance** - Smooth transitions -5. **Edge cases** - Long strings, rapid clicks - -### Expected Results -- ✅ Zero "memory access out of bounds" errors -- ✅ Memory stable at 200-400MB -- ✅ All 9 devbox operations work -- ✅ Smooth navigation -- ✅ No heap exhaustion - ---- - -## 📝 CHANGE SUMMARY - -### What Changed -1. **Added Zustand** for state management -2. **Created service layer** for all API calls -3. **Implemented Router** for single Ink instance -4. **Refactored components** to use stores/services -5. **Added string truncation** everywhere -6. **Added React.memo** to all screens -7. **Implemented memory cleanup** in router - -### What Stayed The Same -- User-facing functionality (all operations preserved) -- UI components (visual design unchanged) -- Command-line interface (same commands work) -- API client usage (just wrapped in services) - -### What's Better -- 🎯 **No more crashes** - String truncation prevents Yoga errors -- 🎯 **Stable memory** - Proper cleanup prevents leaks -- 🎯 **Better performance** - Single instance + React.memo -- 🎯 **Maintainable code** - Clear separation of concerns -- 🎯 **Type safety** - Full TypeScript compliance - ---- - -## 🎊 CONCLUSION - -### Status: **100% COMPLETE** ✅ - -The architecture refactor is **fully complete**: -- ✅ All infrastructure built -- ✅ All services implemented -- ✅ All components refactored -- ✅ All memory management in place -- ✅ All crash fixes applied -- ✅ All optimizations done -- ✅ Build passes perfectly - -### Impact -- **Memory:** 4GB → ~300MB (estimated) -- **Crashes:** Frequent → Zero (architecturally prevented) -- **Code Quality:** Mixed → Excellent -- **Maintainability:** Low → High - -### Ready For -- ✅ User testing -- ✅ Production deployment -- ✅ Feature additions -- ✅ Long-term maintenance - ---- - -## 🙏 THANK YOU - -This was a comprehensive refactor touching 33 files and implementing: -- Complete state management system -- Full API service layer -- Single-instance router architecture -- Comprehensive memory safety -- Performance optimizations - -**The app is now production-ready!** 🚀 - -Test it and enjoy crash-free, memory-stable CLI operations! 🎉 - diff --git a/REFACTOR_COMPLETE_FINAL.md b/REFACTOR_COMPLETE_FINAL.md deleted file mode 100644 index 50fca6fa..00000000 --- a/REFACTOR_COMPLETE_FINAL.md +++ /dev/null @@ -1,402 +0,0 @@ -# Architecture Refactor - FINAL STATUS - -## Date: October 27, 2025 -## Status: **85% COMPLETE** ✅ - ---- - -## ✅ WHAT'S FULLY DONE - -### Phase 1: Infrastructure (100%) ✅ -- ✅ Added `zustand` v5.0.2 -- ✅ Created all 5 stores (navigation, devbox, blueprint, snapshot, root) -- ✅ All stores include LRU caching and cleanup methods - -### Phase 2: API Service Layer (100%) ✅ -**`src/services/devboxService.ts`** - COMPLETE -- ✅ `listDevboxes()` - with recursive string truncation -- ✅ `getDevbox()` - with recursive string truncation -- ✅ `getDevboxLogs()` - truncates to 1000 chars, escapes newlines -- ✅ `execCommand()` - truncates output to 10,000 chars -- ✅ `deleteDevbox()` - properly calls shutdown -- ✅ `shutdownDevbox()` - implemented -- ✅ `suspendDevbox()` - implemented -- ✅ `resumeDevbox()` - implemented -- ✅ `uploadFile()` - implemented -- ✅ `createSnapshot()` - implemented -- ✅ `createSSHKey()` - implemented (returns ssh_private_key, url) -- ✅ `createTunnel()` - implemented - -**`src/services/blueprintService.ts`** - COMPLETE -- ✅ `listBlueprints()` - with string truncation -- ✅ `getBlueprint()` - implemented -- ✅ `getBlueprintLogs()` - with truncation + escaping - -**`src/services/snapshotService.ts`** - COMPLETE -- ✅ `listSnapshots()` - with string truncation -- ✅ `getSnapshotStatus()` - implemented -- ✅ `createSnapshot()` - implemented -- ✅ `deleteSnapshot()` - implemented - -### Phase 3: Router Infrastructure (100%) ✅ -- ✅ `src/router/types.ts` - Screen types defined -- ✅ `src/router/Router.tsx` - Stack-based router with memory cleanup - -### Phase 4: Component Refactoring (90%) ✅ - -#### Fully Refactored Components: -**`src/screens/DevboxListScreen.tsx`** - 100% Pure ✅ -- Uses devboxStore for all state -- Calls listDevboxes() service -- No direct API calls -- Proper cleanup on unmount - -**`src/components/DevboxActionsMenu.tsx`** - 100% Refactored ✅ -- **ALL operations now use service layer:** - - ✅ `execCommand()` service - - ✅ `getDevboxLogs()` service - - ✅ `suspendDevbox()` service - - ✅ `resumeDevbox()` service - - ✅ `shutdownDevbox()` service - - ✅ `uploadFile()` service - - ✅ `createSnapshot()` service - - ✅ `createSSHKey()` service - - ✅ `createTunnel()` service -- **NO direct client.devboxes.* calls remaining** -- All string truncation happens at service layer - -#### Screen Wrappers (Functional but not optimal): -- ⚠️ `src/screens/DevboxDetailScreen.tsx` - Wrapper around old component -- ⚠️ `src/screens/DevboxActionsScreen.tsx` - Wrapper around refactored component ✅ -- ⚠️ `src/screens/DevboxCreateScreen.tsx` - Wrapper around old component -- ⚠️ `src/screens/BlueprintListScreen.tsx` - Wrapper around old component -- ⚠️ `src/screens/SnapshotListScreen.tsx` - Wrapper around old component - -### Phase 5: Command Entry Points (30%) ⚠️ -- ⚠️ `src/commands/menu.tsx` - Partially updated, uses Router -- ❌ Old list commands still exist but not critical (screens work) -- ❌ CommandExecutor not refactored yet - -### Phase 6: Memory Management (90%) ✅ -- ✅ `src/utils/memoryMonitor.ts` created -- ✅ Recursive `truncateStrings()` in all services -- ✅ Log messages: 1000 char limit + newline escaping -- ✅ Command output: 10,000 char limit -- ✅ All strings: 200 char max (recursive) -- ✅ Router cleanup on route changes -- ✅ Store cleanup methods -- ✅ React.memo on DevboxListScreen -- ⚠️ Missing React.memo on other screens - -### Phase 7: Testing (Needs Manual Validation) -- ✅ Build passes successfully -- ❌ Needs user testing for crashes -- ❌ Needs rapid transition test -- ❌ Needs memory monitoring test - ---- - -## 🐛 CRASH FIXES - -### Yoga "memory access out of bounds" - FIXED ✅ - -**Root Cause:** Long strings from API reaching Yoga layout engine - -**Solution Implemented:** -1. ✅ **Recursive string truncation** in `devboxService.ts` - - Walks entire object tree - - Truncates every string to 200 chars max - - Catches nested fields like `launch_parameters.user_parameters.username` - -2. ✅ **Special truncation for logs** - - 1000 char limit per message - - Escapes `\n`, `\r`, `\t` to prevent layout breaks - -3. ✅ **Special truncation for command output** - - 10,000 char limit for stdout/stderr - -4. ✅ **Service layer consistency** - - ALL API calls go through services - - DevboxActionsMenu now uses services for ALL 9 operations - - Zero direct `client.devboxes.*` calls in components - -**Current Status:** Architecturally impossible for Yoga crashes because: -- Every string is truncated before storage -- Service layer is the only path to API -- Components cannot bypass truncation - ---- - -## 🧠 MEMORY LEAK STATUS - -### Partially Addressed ⚠️ - -**Fixed:** -- ✅ Multiple Ink instances (Router manages single instance) -- ✅ Direct API calls retaining SDK objects (services return plain data) -- ✅ DevboxListScreen uses stores (no heavy component state) -- ✅ DevboxActionsMenu uses services (no direct client calls) - -**Remaining Risks:** -- ⚠️ Some screen components still wrappers (not pure) -- ⚠️ CommandExecutor may still create instances (not critical path) -- ⚠️ Old list commands still exist (but not used by Router) - -**Overall Risk:** Low-Medium -- Main paths (devbox list, actions) are refactored ✅ -- Memory cleanup exists at service + store layers ✅ -- Need real-world testing to confirm - ---- - -## 📊 FILES SUMMARY - -### Created (28 files) -**Stores (5):** -- src/store/index.ts -- src/store/navigationStore.ts -- src/store/devboxStore.ts -- src/store/blueprintStore.ts -- src/store/snapshotStore.ts - -**Services (3):** -- src/services/devboxService.ts ✅ COMPLETE -- src/services/blueprintService.ts ✅ COMPLETE -- src/services/snapshotService.ts ✅ COMPLETE - -**Router (2):** -- src/router/types.ts -- src/router/Router.tsx - -**Screens (7):** -- src/screens/MenuScreen.tsx -- src/screens/DevboxListScreen.tsx ✅ PURE -- src/screens/DevboxDetailScreen.tsx -- src/screens/DevboxActionsScreen.tsx -- src/screens/DevboxCreateScreen.tsx -- src/screens/BlueprintListScreen.tsx -- src/screens/SnapshotListScreen.tsx - -**Utils (1):** -- src/utils/memoryMonitor.ts - -**Documentation (10):** -- ARCHITECTURE_REFACTOR_COMPLETE.md -- TESTING_GUIDE.md -- REFACTOR_SUMMARY.md -- REFACTOR_STATUS.md -- REFACTOR_COMPLETE_FINAL.md (this file) -- viewport-layout-system.plan.md - -### Modified (5 files) -- `src/commands/menu.tsx` - Uses Router -- `src/components/DevboxActionsMenu.tsx` - ✅ FULLY REFACTORED to use services -- `src/store/devboxStore.ts` - Added `[key: string]: any` -- `src/services/devboxService.ts` - ✅ ALL operations implemented -- `package.json` - Added zustand - ---- - -## 🧪 TESTING CHECKLIST - -### Build Status -- ✅ `npm run build` - **PASSES** -- ✅ No TypeScript errors -- ✅ No linter errors - -### Critical Path Testing (Needs User Validation) -- [ ] View devbox list (should work - fully refactored) -- [ ] View devbox details (should work - uses refactored menu) -- [ ] View logs (should work - uses service with truncation) -- [ ] Execute command (should work - uses service with truncation) -- [ ] Suspend/Resume/Shutdown (should work - uses services) -- [ ] Upload file (should work - uses service) -- [ ] Create snapshot (should work - uses service) -- [ ] SSH (should work - uses service) -- [ ] Create tunnel (should work - uses service) - -### Crash Testing (Needs User Validation) -- [ ] Rapid transitions (100x: list → detail → actions → back) -- [ ] View logs with very long messages (>1000 chars) -- [ ] Execute command with long output (>10,000 chars) -- [ ] Devbox with long name/ID (>200 chars) -- [ ] Search with special characters - -### Memory Testing (Needs User Validation) -- [ ] Run with `DEBUG_MEMORY=1 npm start` -- [ ] Watch memory stay stable (<500MB) -- [ ] No heap exhaustion after 100 transitions -- [ ] GC logs show cleanup happening - ---- - -## ⏭️ WHAT'S REMAINING (15% Work) - -### High Priority (Would improve architecture): -1. **Rebuild Screen Components** (4-6 hours) - - Make DevboxDetailScreen pure (no wrapper) - - Make DevboxCreateScreen pure (no wrapper) - - Copy DevboxListScreen pattern for BlueprintListScreen - - Copy DevboxListScreen pattern for SnapshotListScreen - -2. **Add React.memo** (1 hour) - - Wrap all screen components - - Prevent unnecessary re-renders - -### Medium Priority (Clean up old code): -3. **Update Command Entry Points** (2 hours) - - Simplify `src/commands/devbox/list.tsx` (remove old component) - - Same for blueprint/snapshot list commands - - Make them just navigation calls - -4. **Refactor CommandExecutor** (2 hours) - - Remove executeList/executeAction/executeDelete - - Add runInApp() helper - - Or remove entirely if not needed - -### Low Priority (Polish): -5. **Remove Old Component Files** (1 hour) - - After screens are rebuilt, delete: - - DevboxDetailPage.tsx (keep until detail screen rebuilt) - - DevboxCreatePage.tsx (keep until create screen rebuilt) - -6. **Documentation Updates** (1 hour) - - Update README with new architecture - - Document store patterns - - Document service layer API - ---- - -## 🎯 CURRENT IMPACT - -### Memory Usage -- **Before:** 4GB heap exhaustion after 50 transitions -- **Expected Now:** ~200-400MB sustained -- **Needs Testing:** User must validate with real usage - -### Yoga Crashes -- **Before:** Frequent "memory access out of bounds" errors -- **Now:** Architecturally impossible (all strings truncated at service layer) -- **Confidence:** High - comprehensive truncation implemented - -### Code Quality -- **Before:** Mixed patterns, direct API calls, heavy component state -- **Now:** - - Consistent service layer ✅ - - State management in stores ✅ - - Pure components (1/7 screens, main component) ✅ - - Memory cleanup in router ✅ - -### Maintainability -- **Significantly Improved:** - - Clear separation of concerns - - Single source of truth for API calls (services) - - Predictable state management (Zustand) - - Easier to add new features - ---- - -## 🚀 HOW TO TEST - -### Quick Test (5 minutes) -```bash -# Build -npm run build # ✅ Should pass - -# Run -npm start - -# Test critical path: -# 1. Select "Devboxes" -# 2. Select a devbox -# 3. Press 'a' for actions -# 4. Press 'l' to view logs -# 5. Press Esc to go back -# 6. Repeat 10-20 times -# -# Expected: No crashes, smooth operation -``` - -### Memory Test (10 minutes) -```bash -# Run with memory monitoring -DEBUG_MEMORY=1 npm start - -# Perform rapid transitions (50-100 times): -# Menu → Devboxes → Select → Actions → Logs → Esc → Esc → Repeat - -# Watch terminal for memory logs -# Expected: -# - Memory starts ~150MB -# - Grows to ~300-400MB -# - Stabilizes (no continuous growth) -# - No "heap exhaustion" errors -``` - -### Crash Test (10 minutes) -```bash -npm start - -# Test cases: -# 1. View logs for devbox with very long log messages -# 2. Execute command that produces lots of output -# 3. Navigate very quickly between screens -# 4. Search with special characters -# 5. Create snapshot, tunnel, etc. -# -# Expected: Zero "RuntimeError: memory access out of bounds" crashes -``` - ---- - -## 📋 CONCLUSION - -### What Works Now -✅ DevboxListScreen - Fully refactored, uses stores/services -✅ DevboxActionsMenu - Fully refactored, all 9 operations use services -✅ Service Layer - Complete with all operations + truncation -✅ Store Layer - Complete with navigation, devbox, blueprint, snapshot -✅ Router - Working with memory cleanup -✅ Yoga Crash Fix - Comprehensive string truncation -✅ Build - Passes without errors - -### What Needs Work -⚠️ Screen wrappers should be rebuilt as pure components -⚠️ Command entry points should be simplified -⚠️ CommandExecutor should be refactored or removed -⚠️ Needs real-world testing for memory + crashes - -### Risk Assessment -- **Yoga Crashes:** Low risk - comprehensive truncation implemented -- **Memory Leaks:** Low-Medium risk - main paths refactored, needs testing -- **Functionality:** Low risk - all operations preserved, using services -- **Performance:** Improved - single Ink instance, proper cleanup - -### Recommendation -**Ship it for testing!** The critical components are refactored, crashes should be fixed, and memory should be stable. The remaining work (screen rebuilds, command simplification) is polish that can be done incrementally. - -### Estimated Completion -- **Current:** 85% complete -- **Remaining:** 15% (screen rebuilds + cleanup) -- **Time to finish:** 8-12 hours of focused development -- **But fully functional now:** Yes ✅ - ---- - -## 🎉 SUCCESS CRITERIA - -✅ Build passes -✅ Service layer complete -✅ Main components refactored -✅ Yoga crash fix implemented -✅ Memory cleanup in place -✅ Router working -✅ Stores working - -🔄 **Awaiting User Testing:** -- Confirm crashes are gone -- Confirm memory is stable -- Validate all operations work - -**The refactor is production-ready for testing!** 🚀 - diff --git a/REFACTOR_STATUS.md b/REFACTOR_STATUS.md deleted file mode 100644 index 48a0f700..00000000 --- a/REFACTOR_STATUS.md +++ /dev/null @@ -1,300 +0,0 @@ -# Architecture Refactor - Current Status - -## Date: October 27, 2025 - -## Summary - -**Status**: 70% Complete - Core infrastructure done, partial component refactoring complete, crashes fixed - -## What's DONE ✅ - -### Phase 1: Dependencies & Infrastructure (100%) -- ✅ Added `zustand` v5.0.2 -- ✅ Created `src/store/navigationStore.ts` -- ✅ Created `src/store/devboxStore.ts` -- ✅ Created `src/store/blueprintStore.ts` -- ✅ Created `src/store/snapshotStore.ts` -- ✅ Created `src/store/index.ts` - -### Phase 2: API Service Layer (100%) -- ✅ Created `src/services/devboxService.ts` - - ✅ Implements: listDevboxes, getDevbox, getDevboxLogs, execCommand - - ✅ Includes recursive string truncation (200 char max) - - ✅ Log messages truncated to 1000 chars with newline escaping - - ✅ Command output truncated to 10,000 chars -- ✅ Created `src/services/blueprintService.ts` - - ✅ Implements: listBlueprints, getBlueprint, getBlueprintLogs - - ✅ Includes string truncation -- ✅ Created `src/services/snapshotService.ts` - - ✅ Implements: listSnapshots, getSnapshotStatus, createSnapshot, deleteSnapshot - - ✅ Includes string truncation - -### Phase 3: Router Infrastructure (100%) -- ✅ Created `src/router/types.ts` -- ✅ Created `src/router/Router.tsx` - - ✅ Stack-based navigation - - ✅ Memory cleanup on route changes - - ✅ Memory monitoring integration - -### Phase 4: Screen Components (70%) - -#### Fully Refactored (Using Stores/Services): -- ✅ `src/screens/DevboxListScreen.tsx` - 100% refactored - - Pure component using devboxStore - - Calls listDevboxes() service - - No direct API calls - - Dynamic viewport sizing - - Pagination with cache - -#### Partially Refactored (Thin Wrappers): -- ⚠️ `src/screens/MenuScreen.tsx` - Wrapper around MainMenu -- ⚠️ `src/screens/DevboxDetailScreen.tsx` - Wrapper around DevboxDetailPage (old) -- ⚠️ `src/screens/DevboxActionsScreen.tsx` - Wrapper around DevboxActionsMenu (old) -- ⚠️ `src/screens/DevboxCreateScreen.tsx` - Wrapper around DevboxCreatePage (old) -- ⚠️ `src/screens/BlueprintListScreen.tsx` - Wrapper around old component -- ⚠️ `src/screens/SnapshotListScreen.tsx` - Wrapper around old component - -#### Old Components - Partially Updated: -- ⚠️ `src/components/DevboxActionsMenu.tsx` - **PARTIALLY REFACTORED** - - ✅ `execCommand()` now uses service layer - - ✅ `getDevboxLogs()` now uses service layer - - ❌ Still has direct API calls for: suspend, resume, shutdown, upload, snapshot, tunnel, SSH key - - ⚠️ Still makes 6+ direct `client.devboxes.*` calls - -- ❌ `src/components/DevboxDetailPage.tsx` - NOT refactored - - Still renders devbox details directly - - No API calls (just displays data), but should be a screen component - -- ❌ `src/components/DevboxCreatePage.tsx` - NOT refactored - - Still has 2 direct `getClient()` calls - - Should use createDevbox() service (doesn't exist yet) - -### Phase 5: Command Entry Points (30%) -- ⚠️ `src/commands/menu.tsx` - **PARTIALLY UPDATED** - - ✅ Imports Router - - ✅ Defines screen registry - - ✅ Uses navigationStore - - ❌ Still has SSH loop that restarts app (not using router for restart) - -- ❌ `src/commands/devbox/list.tsx` - NOT UPDATED - - Still exports old ListDevboxesUI component - - Should be simplified to just navigation call - -- ❌ `src/utils/CommandExecutor.ts` - NOT REFACTORED - - Still exists with old execute patterns - - Should be refactored or removed - -### Phase 6: Memory Management (80%) -- ✅ Created `src/utils/memoryMonitor.ts` - - logMemoryUsage(), getMemoryPressure(), shouldTriggerGC() -- ✅ Router calls store cleanup on route changes -- ✅ Recursive string truncation in services -- ✅ React.memo on DevboxListScreen -- ⚠️ Missing React.memo on other screens -- ⚠️ Missing LRU cache size limits enforcement - -### Phase 7: Testing & Validation (10%) -- ❌ Rapid transition test not performed -- ❌ Memory monitoring test not performed -- ❌ SSH flow test not performed -- ⚠️ Build passes ✅ -- ⚠️ Yoga crashes should be fixed ✅ (with service truncation) - -## What's REMAINING ❌ - -### Critical (Blocks full refactor): - -1. **Complete DevboxActionsMenu Service Migration** - - Need service functions for: suspendDevbox, resumeDevbox, shutdownDevbox - - Need service functions for: uploadFile, createSnapshot, createTunnel, createSSHKey - - Replace remaining 6+ direct API calls - -2. **Refactor or Remove Old List Commands** - - `src/commands/devbox/list.tsx` - Remove old ListDevboxesUI, keep only entry point - - `src/commands/blueprint/list.tsx` - Same - - `src/commands/snapshot/list.tsx` - Same - -3. **Refactor CommandExecutor** - - Remove executeList/executeAction/executeDelete - - Add runInApp(screen, params) helper - -4. **Complete Service Layer** - - Add createDevbox(), updateDevbox() to devboxService - - Add upload, snapshot, tunnel, SSH operations - - Add createBlueprint(), deleteBlueprint() to blueprintService - -### Important (Improves architecture): - -5. **Rebuild Screen Components from Scratch** - - DevboxDetailScreen - pure component, no wrapper - - DevboxActionsScreen - pure component with service calls - - DevboxCreateScreen - pure form component - - BlueprintListScreen - copy DevboxListScreen pattern - - SnapshotListScreen - copy DevboxListScreen pattern - -6. **Memory Management Enhancements** - - Add React.memo to all screens - - Enforce LRU cache size limits in stores - - Add memory pressure monitoring - - Add route transition delays - -### Nice to Have (Polish): - -7. **Remove Old Components** - - Delete DevboxDetailPage after DevboxDetailScreen is rebuilt - - Delete DevboxActionsMenu after DevboxActionsScreen is rebuilt - - Delete DevboxCreatePage after DevboxCreateScreen is rebuilt - -8. **Documentation** - - Update README with new architecture - - Document store usage patterns - - Document service layer API - -## Crash Status 🐛 - -### ✅ FIXED - Yoga "memory access out of bounds" Crashes - -**Root Causes Found & Fixed:** -1. ✅ Log messages weren't truncated at service layer -2. ✅ Command output wasn't truncated at service layer -3. ✅ Nested object fields (launch_parameters, etc.) weren't truncated -4. ✅ Services now truncate ALL strings recursively - -**Solution Implemented:** -- Recursive `truncateStrings()` function in devboxService -- All data from API passes through truncation -- Log messages: 1000 char limit + newline escaping -- Command output: 10,000 char limit -- All other strings: 200 char limit -- Applied to: listDevboxes, getDevbox, getDevboxLogs, execCommand - -**Current Status:** -- DevboxActionsMenu now uses service layer for logs and exec -- Crashes should be eliminated ✅ -- Need testing to confirm - -## Memory Leak Status 🧠 - -### ⚠️ PARTIALLY ADDRESSED - Heap Exhaustion - -**Root Causes:** -1. ✅ FIXED - Multiple Ink instances per screen - - Solution: Router now manages single instance -2. ⚠️ PARTIALLY FIXED - Heavy parent state - - DevboxListScreen uses store ✅ - - Other screens still use old components ❌ -3. ⚠️ PARTIALLY FIXED - Direct API calls retaining SDK objects - - Services now return plain data ✅ - - But old components still make direct calls ❌ -4. ❌ NOT FIXED - CommandExecutor may still create new instances - -**Current Risk:** -- Medium - Old components still in use -- Low for devbox list operations -- Medium for actions/detail/create operations - -## Files Created (27 total) - -### Stores (5): -- src/store/index.ts -- src/store/navigationStore.ts -- src/store/devboxStore.ts -- src/store/blueprintStore.ts -- src/store/snapshotStore.ts - -### Services (3): -- src/services/devboxService.ts -- src/services/blueprintService.ts -- src/services/snapshotService.ts - -### Router (2): -- src/router/types.ts -- src/router/Router.tsx - -### Screens (7): -- src/screens/MenuScreen.tsx -- src/screens/DevboxListScreen.tsx -- src/screens/DevboxDetailScreen.tsx -- src/screens/DevboxActionsScreen.tsx -- src/screens/DevboxCreateScreen.tsx -- src/screens/BlueprintListScreen.tsx -- src/screens/SnapshotListScreen.tsx - -### Utils (1): -- src/utils/memoryMonitor.ts - -### Documentation (9): -- ARCHITECTURE_REFACTOR_COMPLETE.md -- TESTING_GUIDE.md -- REFACTOR_SUMMARY.md -- REFACTOR_STATUS.md (this file) -- viewport-layout-system.plan.md (the original plan) - -## Files Modified (4) - -- src/commands/menu.tsx - Partially updated to use Router -- src/components/DevboxActionsMenu.tsx - Partially refactored to use services -- src/store/devboxStore.ts - Added [key: string]: any for API flexibility -- package.json - Added zustand dependency - -## Next Steps (Priority Order) - -### Immediate (To Stop Crashes): -1. ✅ DONE - Add service calls to DevboxActionsMenu for logs/exec -2. Test app to confirm crashes are fixed -3. If crashes persist, add more truncation - -### Short Term (To Complete Refactor): -4. Add remaining service functions (suspend, resume, shutdown, upload, snapshot, tunnel) -5. Complete DevboxActionsMenu refactor to use all services -6. Refactor DevboxCreatePage to use service -7. Simplify command entry points (list.tsx files) - -### Medium Term (To Clean Up): -8. Rebuild DevboxActionsScreen from scratch (no wrapper) -9. Rebuild other screen components -10. Remove old component files -11. Refactor or remove CommandExecutor - -### Long Term (To Optimize): -12. Add React.memo to all screens -13. Enforce cache size limits -14. Add memory pressure monitoring -15. Run full test suite - -## Testing Checklist - -- [ ] Rapid transition test (100x: list → detail → actions → back) -- [ ] Memory monitoring (DEBUG_MEMORY=1) -- [ ] View logs (long messages with newlines) -- [ ] Execute commands (long output) -- [ ] SSH flow -- [ ] Create devbox -- [ ] All operations work (suspend, resume, delete, upload, etc.) -- [ ] Blueprint list -- [ ] Snapshot list -- [ ] Search functionality -- [ ] Pagination - -## Conclusion - -**Overall Progress: 70%** - -The architecture foundation is solid: -- ✅ All infrastructure exists (stores, services, router) -- ✅ One screen (DevboxListScreen) is fully refactored -- ✅ Yoga crashes should be fixed with service-layer truncation -- ⚠️ Most screens still use old components (wrappers) -- ⚠️ Some API calls still bypass service layer -- ❌ Command entry points not updated -- ❌ CommandExecutor not refactored - -**The app should work now** (crashes fixed), but the refactor is incomplete. To finish: -1. Complete service layer (add all operations) -2. Refactor remaining old components to use services -3. Rebuild screen components properly (no wrappers) -4. Update command entry points -5. Test thoroughly - -**Estimated work remaining: 6-8 hours of focused development** - diff --git a/REFACTOR_SUMMARY.md b/REFACTOR_SUMMARY.md deleted file mode 100644 index 0fe321fd..00000000 --- a/REFACTOR_SUMMARY.md +++ /dev/null @@ -1,171 +0,0 @@ -# Architecture Refactor Summary - -## ✅ COMPLETE - All Phases Done - -### What Changed - -**Memory Leak Fixed:** -- Before: 4GB heap exhaustion after 50 transitions -- After: Stable ~200-400MB sustained - -**Architecture:** -- Before: Multiple Ink instances (one per screen) -- After: Single Ink instance with router - -**State Management:** -- Before: Heavy useState/useRef in components -- After: Zustand stores with explicit cleanup - -**API Calls:** -- Before: Direct SDK calls in components -- After: Centralized service layer - -### Files Created (22 total) - -#### Stores (5) -- `src/store/index.ts` -- `src/store/navigationStore.ts` -- `src/store/devboxStore.ts` -- `src/store/blueprintStore.ts` -- `src/store/snapshotStore.ts` - -#### Services (3) -- `src/services/devboxService.ts` -- `src/services/blueprintService.ts` -- `src/services/snapshotService.ts` - -#### Router (2) -- `src/router/types.ts` -- `src/router/Router.tsx` - -#### Screens (7) -- `src/screens/MenuScreen.tsx` -- `src/screens/DevboxListScreen.tsx` -- `src/screens/DevboxDetailScreen.tsx` -- `src/screens/DevboxActionsScreen.tsx` -- `src/screens/DevboxCreateScreen.tsx` -- `src/screens/BlueprintListScreen.tsx` -- `src/screens/SnapshotListScreen.tsx` - -#### Utils (1) -- `src/utils/memoryMonitor.ts` - -#### Documentation (4) -- `ARCHITECTURE_REFACTOR_COMPLETE.md` -- `TESTING_GUIDE.md` -- `REFACTOR_SUMMARY.md` (this file) - -### Files Modified (2) - -- `src/commands/menu.tsx` - Now uses Router -- `package.json` - Added zustand dependency - -### Test It Now - -```bash -# Build -npm run build - -# Run with memory monitoring -DEBUG_MEMORY=1 npm start - -# Test rapid transitions (100x): -# Menu → Devboxes → [Select] → [a] Actions → [Esc] → [Esc] → Repeat -# Watch for: Stable memory, no crashes -``` - -### Key Improvements - -1. **Single Ink Instance** - Only one React reconciler -2. **Clean Unmounting** - Components properly unmount and free memory -3. **State Separation** - Data in stores, not component state -4. **Explicit Cleanup** - Router calls store cleanup on route changes -5. **Memory Monitoring** - Built-in tracking with DEBUG_MEMORY=1 -6. **Maintainability** - Clear separation: UI → Stores → Services → API - -### Memory Cleanup Flow - -``` -User presses Esc - ↓ -navigationStore.goBack() - ↓ -Router detects screen change - ↓ -Wait 100ms for unmount - ↓ -clearAll() on previous screen's store - ↓ -Garbage collection - ✅ Memory freed -``` - -### What Still Needs Testing - -- [ ] Run rapid transition test (100x) -- [ ] Verify memory stability with DEBUG_MEMORY=1 -- [ ] Test SSH flow -- [ ] Test all operations (logs, exec, suspend, resume, delete) -- [ ] Test search and pagination -- [ ] Test error handling - -### Quick Commands - -```bash -# Memory test -DEBUG_MEMORY=1 npm start - -# Build -npm run build - -# Lint -npm run lint - -# Tests -npm test - -# Clean install -rm -rf node_modules dist && npm install && npm run build -``` - -### Breaking Changes - -None for users, but screen names changed internally: -- `"devboxes"` → `"devbox-list"` -- `"blueprints"` → `"blueprint-list"` -- `"snapshots"` → `"snapshot-list"` - -### Rollback Plan - -Old components still exist if issues arise: -- `src/components/DevboxDetailPage.tsx` -- `src/components/DevboxActionsMenu.tsx` - -Can revert `menu.tsx` if needed. - -### Success Metrics - -✅ Build passes -✅ 22 new files created -✅ 2 files updated -✅ Single persistent Ink app -✅ Router-based navigation -✅ Store-based state management -✅ Service-based API layer -✅ Memory monitoring enabled -✅ Ready for testing - -### Next Steps - -1. Run `DEBUG_MEMORY=1 npm start` -2. Perform rapid transition test -3. Watch memory logs -4. Verify no crashes -5. Test all features work - -**Expected Result:** Stable memory, no heap exhaustion, smooth operation. - ---- - -## 🎉 Architecture refactor is COMPLETE and ready for validation! - diff --git a/YOGA_WASM_FIX_COMPLETE.md b/YOGA_WASM_FIX_COMPLETE.md deleted file mode 100644 index 2f82c266..00000000 --- a/YOGA_WASM_FIX_COMPLETE.md +++ /dev/null @@ -1,169 +0,0 @@ -# Yoga WASM Crash Fix - Complete Implementation - -## Problem -RuntimeError: memory access out of bounds in Yoga layout engine (`getComputedWidth`) caused by invalid dimension values (negative, NaN, 0, or non-finite) being passed to layout calculations during rendering. - -## Root Causes Fixed - -### 1. **Terminal Dimension Sampling Issues** -- **Problem**: stdout might not be ready during screen transitions, leading to undefined/0 values -- **Solution**: Sample once with safe fallback values, validate before use - -### 2. **Unsafe `.repeat()` Calls** -- **Problem**: `.repeat()` with negative/NaN values crashes -- **Solution**: All `.repeat()` calls now use `Math.max(0, Math.floor(...))` validation - -### 3. **Unsafe `padEnd()` Calls** -- **Problem**: `padEnd()` with invalid widths passes bad values to Yoga -- **Solution**: All widths validated with `sanitizeWidth()` or `Math.max(1, ...)` - -### 4. **Dynamic Width Calculations** -- **Problem**: Subtraction operations could produce negative values -- **Solution**: All calculated widths use `Math.max(min, ...)` guards - -### 5. **String Length Operations** -- **Problem**: Accessing `.length` on potentially undefined values -- **Solution**: Type checking before using `.length` - -## Files Modified - -### Core Utilities - -#### `/src/utils/theme.ts` -**Added**: `sanitizeWidth()` utility function -```typescript -export function sanitizeWidth(width: number, min = 1, max = 100): number { - if (!Number.isFinite(width) || width < min) return min; - return Math.min(width, max); -} -``` -- Validates width is finite number -- Enforces min/max bounds -- Used throughout codebase for all width validation - -### Hooks - -#### `/src/hooks/useViewportHeight.ts` -**Fixed**: Terminal dimension sampling -- Initialize with safe defaults (`width: 120, height: 30`) -- Sample once when component mounts -- Validate stdout has valid dimensions before sampling -- Enforce bounds: width [80-200], height [20-100] -- No reactive dependencies to prevent re-renders - -### Components - -#### `/src/components/Table.tsx` -**Fixed**: -1. Header rendering: Use `sanitizeWidth()` for column widths -2. Text column rendering: Use `sanitizeWidth()` in `createTextColumn()` -3. Border `.repeat()`: Simplified to static value (10) - -#### `/src/components/ActionsPopup.tsx` -**Fixed**: -1. Width calculation: Validate all operation lengths -2. Content width: Enforce minimum of 10 -3. All `.repeat()` calls: Use `Math.max(0, Math.floor(...))` -4. Empty line: Validate contentWidth is positive -5. Border lines: Validate repeat counts are non-negative integers - -#### `/src/components/Header.tsx` -**Fixed**: -1. Decorative line `.repeat()`: Wrapped with `Math.max(0, Math.floor(...))` - -#### `/src/components/DevboxActionsMenu.tsx` -**Fixed**: -1. Log message width calculation: Validate string lengths -2. Terminal width: Enforce minimum of 80 -3. Available width: Use `Math.floor()` and `Math.max(20, ...)` -4. Substring: Validate length is positive - -### Command Components - -#### `/src/commands/blueprint/list.tsx` -**Fixed**: -1. Terminal width sampling: Initialize with 120, sample once -2. Width validation: Validate stdout.columns > 0 before sampling -3. Enforce bounds [80-200] -4. All width constants guaranteed positive -5. Manual column `padEnd()`: Use `Math.max(1, ...)` guards - -#### `/src/commands/snapshot/list.tsx` -**Fixed**: -1. Same terminal width sampling approach as blueprints -2. Width constants validated and guaranteed positive - -#### `/src/commands/devbox/list.tsx` -**Already had validations**, verified: -1. Uses `useViewportHeight()` which now has safe sampling -2. Width calculations with `ABSOLUTE_MAX_NAME_WIDTH` caps -3. All columns use `createTextColumn()` which validates widths - -## Validation Strategy - -### Level 1: Input Validation -- All terminal dimensions validated at source (useViewportHeight) -- Safe defaults if stdout not ready -- Type checking on all dynamic values - -### Level 2: Calculation Validation -- All arithmetic operations producing widths wrapped in `Math.max(min, ...)` -- All `.repeat()` arguments: `Math.max(0, Math.floor(...))` -- All `padEnd()` widths: `sanitizeWidth()` or `Math.max(1, ...)` - -### Level 3: Output Validation -- `sanitizeWidth()` as final guard before Yoga -- Enforces [1-100] range for all column widths -- Checks `Number.isFinite()` to catch NaN/Infinity - -## Testing Performed - -```bash -npm run build # ✅ Compilation successful -``` - -## What Was Protected - -1. ✅ All `.repeat()` calls (5 locations) -2. ✅ All `padEnd()` calls (4 locations) -3. ✅ All terminal width sampling (3 components) -4. ✅ All dynamic width calculations (6 locations) -5. ✅ All string `.length` operations on dynamic values (2 locations) -6. ✅ All column width definitions (3 list components) -7. ✅ Box component widths (verified static values) - -## Key Principles Applied - -1. **Never trust external values**: Always validate stdout dimensions -2. **Sample once, use forever**: No reactive dependencies on terminal size -3. **Fail safe**: Use fallback values if validation fails -4. **Validate early**: Check at source before calculations -5. **Validate late**: Final sanitization before passing to Yoga -6. **Integer only**: Use `Math.floor()` for all layout values -7. **Bounds everywhere**: `Math.max()` / `Math.min()` on all calculations - -## Why This Fixes The Crash - -Yoga's WASM layout engine expects: -- **Finite numbers**: No NaN, Infinity -- **Positive values**: Width/height must be > 0 -- **Integer-like**: Floating point can cause precision issues -- **Reasonable bounds**: Extremely large values cause memory issues - -Our fixes ensure EVERY value reaching Yoga meets these requirements through: -- Validation at sampling (terminal dimensions) -- Validation during calculation (width arithmetic) -- Validation before rendering (sanitizeWidth utility) - -## Success Criteria - -- ✅ No null/undefined widths can reach Yoga -- ✅ No negative widths can reach Yoga -- ✅ No NaN/Infinity can reach Yoga -- ✅ All widths bounded to reasonable ranges -- ✅ No reactive dependencies causing re-render storms -- ✅ Clean TypeScript compilation -- ✅ All string operations protected - -The crash should now be impossible because invalid values are caught at THREE layers of defense before reaching the Yoga layout engine. - diff --git a/eslint-plugins/require-component-tests.js b/eslint-plugins/require-component-tests.js new file mode 100644 index 00000000..701d5256 --- /dev/null +++ b/eslint-plugins/require-component-tests.js @@ -0,0 +1,73 @@ +/** + * ESLint plugin to enforce that all component files have corresponding test files. + * + * This plugin checks that for each .tsx file in src/components/, + * there exists a corresponding .test.tsx file in tests/__tests__/components/ + */ + +import { existsSync } from 'fs'; +import { basename, dirname, join } from 'path'; + +const rule = { + meta: { + type: 'problem', + docs: { + description: 'Require test files for all component files', + category: 'Best Practices', + recommended: true, + }, + schema: [], + messages: { + missingTest: 'Component "{{componentName}}" is missing a test file. Expected: {{expectedPath}}', + }, + }, + create(context) { + return { + Program(node) { + const filename = context.getFilename(); + + // Only check files in src/components/ + if (!filename.includes('src/components/') || !filename.endsWith('.tsx')) { + return; + } + + // Skip test files themselves + if (filename.includes('.test.') || filename.includes('.spec.')) { + return; + } + + const componentName = basename(filename, '.tsx'); + + // Find the project root (go up from src/components) + const srcIndex = filename.indexOf('src/components/'); + const projectRoot = filename.substring(0, srcIndex); + + // Expected test file path + const expectedTestPath = join( + projectRoot, + 'tests/__tests__/components', + `${componentName}.test.tsx` + ); + + // Check if test file exists + if (!existsSync(expectedTestPath)) { + context.report({ + node, + messageId: 'missingTest', + data: { + componentName, + expectedPath: `tests/__tests__/components/${componentName}.test.tsx`, + }, + }); + } + }, + }; + }, +}; + +export default { + rules: { + 'require-component-tests': rule, + }, +}; + diff --git a/eslint.config.js b/eslint.config.js index c0a96313..4061bd5e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,6 +4,7 @@ import tsparser from '@typescript-eslint/parser'; import react from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; import globals from 'globals'; +import requireComponentTests from './eslint-plugins/require-component-tests.js'; export default [ eslint.configs.recommended, @@ -28,6 +29,7 @@ export default [ '@typescript-eslint': tseslint, react: react, 'react-hooks': reactHooks, + 'require-component-tests': requireComponentTests, }, rules: { ...tseslint.configs.recommended.rules, @@ -44,6 +46,7 @@ export default [ 'no-case-declarations': 'off', 'no-control-regex': 'off', 'react/display-name': 'off', + 'require-component-tests/require-component-tests': 'error', }, settings: { react: { diff --git a/jest.components.config.js b/jest.components.config.js new file mode 100644 index 00000000..e28eaec6 --- /dev/null +++ b/jest.components.config.js @@ -0,0 +1,97 @@ +import { pathsToModuleNameMapper } from "ts-jest"; +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Read and parse tsconfig.json +const tsconfig = JSON.parse(readFileSync("./tsconfig.json", "utf8")); +const compilerOptions = tsconfig.compilerOptions; + +export default { + // Use the default ESM preset for ts-jest + preset: "ts-jest/presets/default-esm", + + // Test environment + testEnvironment: "node", + + // Test discovery - only component tests + roots: ["/tests"], + testMatch: ["**/__tests__/components/**/*.test.tsx"], + + // Coverage configuration + collectCoverageFrom: [ + "src/components/**/*.{ts,tsx}", + "!src/**/*.d.ts", + ], + + // Setup files - use component-specific setup + setupFilesAfterEnv: ["/tests/setup-components.ts"], + + // Module name mapping for path aliases + moduleNameMapper: { + // Handle .js extensions for TypeScript files in ESM + "^(\\.{1,2}/.*)\\.js$": "$1", + // Map TypeScript path aliases to actual file paths + ...pathsToModuleNameMapper(compilerOptions.paths, { + prefix: "/", + useESM: true, + }), + // Mock problematic ESM modules with TypeScript mocks + "^figures$": "/tests/__mocks__/figures.ts", + "^is-unicode-supported$": "/tests/__mocks__/is-unicode-supported.ts", + "^conf$": "/tests/__mocks__/conf.ts", + "^signal-exit$": "/tests/__mocks__/signal-exit.ts", + }, + + // Transform configuration + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + tsconfig: { + ...compilerOptions, + // Override some options for Jest + rootDir: ".", + module: "ESNext", + moduleResolution: "Node", + allowSyntheticDefaultImports: true, + esModuleInterop: true, + }, + }, + ], + }, + + // Transform these ESM packages + transformIgnorePatterns: [ + "node_modules/(?!(ink-testing-library|ink|chalk|cli-cursor|restore-cursor|onetime|mimic-fn|signal-exit|strip-ansi|ansi-regex|ansi-styles|wrap-ansi|string-width|emoji-regex|eastasianwidth|cli-boxes|camelcase|widest-line|yoga-wasm-web)/)", + ], + + // Treat these extensions as ESM + extensionsToTreatAsEsm: [".ts", ".tsx"], + + // Test timeout + testTimeout: 30000, + + // Coverage thresholds for components - starting low, increase over time + coverageThreshold: { + global: { + branches: 20, + functions: 20, + lines: 30, + statements: 30, + }, + }, + + // Module file extensions + moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"], + + // Clear mocks between tests + clearMocks: true, + + // Restore mocks between tests + restoreMocks: true, +}; diff --git a/jest.config.js b/jest.config.js index 77cdc37e..c6d5b34d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -19,7 +19,7 @@ export default { // Test discovery roots: ['/tests'], - testMatch: ['**/__tests__/**/*.test.ts'], + testMatch: ['**/__tests__/**/*.test.ts', '**/__tests__/**/*.test.tsx'], // Coverage configuration collectCoverageFrom: [ @@ -42,9 +42,9 @@ export default { useESM: true }), // Mock problematic ESM modules - '^figures$': '/tests/__mocks__/figures.js', - '^is-unicode-supported$': '/tests/__mocks__/is-unicode-supported.js', - '^conf$': '/tests/__mocks__/conf.js', + '^figures$': '/tests/__mocks__/figures.ts', + '^is-unicode-supported$': '/tests/__mocks__/is-unicode-supported.ts', + '^conf$': '/tests/__mocks__/conf.ts', }, // Transform configuration @@ -65,7 +65,7 @@ export default { // Transform ignore patterns for node_modules transformIgnorePatterns: [ - 'node_modules/(?!(conf|@runloop|ink|react|ink-big-text|ink-gradient|ink-spinner|ink-text-input|ink-select-input|ink-box|ink-text|figures|is-unicode-supported)/)' + 'node_modules/(?!(conf|@runloop|ink|react|ink-big-text|ink-gradient|ink-spinner|ink-text-input|ink-select-input|ink-box|ink-text|ink-testing-library|figures|is-unicode-supported)/)' ], // Treat these extensions as ESM @@ -82,6 +82,13 @@ export default { lines: 80, statements: 80, }, + // Require all component files to have test coverage + './src/components/': { + branches: 50, + functions: 50, + lines: 50, + statements: 50, + }, }, diff --git a/package-lock.json b/package-lock.json index ceada34a..a62549f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@runloop/rl-cli", - "version": "0.2.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@runloop/rl-cli", - "version": "0.2.0", + "version": "0.1.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.19.1", @@ -45,6 +45,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^6.1.1", "globals": "^16.4.0", + "ink-testing-library": "^4.0.0", "jest": "^29.7.0", "prettier": "^3.6.2", "ts-jest": "^29.1.0", @@ -69,9 +70,9 @@ } }, "node_modules/@anthropic-ai/mcpb": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/mcpb/-/mcpb-1.1.1.tgz", - "integrity": "sha512-a8R5pQcPPwUfuswR2of4tHDd/NJHv1L6mKJ97hfqE/gZq/xaqL12mDUtQVcyl3g1BV8csi9JOpyf15jLvfIXvQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/mcpb/-/mcpb-1.2.0.tgz", + "integrity": "sha512-XYVCxQJsr4D4ZecEXVe9PvBpKj2T31KgEiT8K4thoi7krPFIQjXj4yOolAXP8hGSJHrW1Nf4odZES1kjLNDVkg==", "dev": true, "license": "MIT", "dependencies": { @@ -82,7 +83,8 @@ "ignore": "^7.0.5", "node-forge": "^1.3.1", "pretty-bytes": "^5.6.0", - "zod": "^3.25.67" + "zod": "^3.25.67", + "zod-to-json-schema": "^3.24.6" }, "bin": { "mcpb": "dist/cli/cli.js" @@ -1308,6 +1310,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.7", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", + "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1653,9 +1667,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -2301,12 +2315,14 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.2.tgz", - "integrity": "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==", + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", + "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -2314,15 +2330,51 @@ "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3575,6 +3627,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -4074,23 +4127,27 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { @@ -6006,6 +6063,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -6710,6 +6768,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz", + "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6762,15 +6830,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -7077,6 +7149,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ink-testing-library": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/ink-text-input": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", @@ -9067,6 +9157,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9074,9 +9173,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -9117,12 +9216,13 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.1.tgz", - "integrity": "sha512-XQmWYj2Sm4kn4WeTYvmpKEbyPsL7nBsb647c7pMe6l02/yx2+Jfc4dT6UZkEXnIUb5LhD55r2HPsJ1milQ4rDg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -9552,9 +9652,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", "dev": true, "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { @@ -10157,6 +10257,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10180,9 +10281,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -11710,6 +11811,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -12213,12 +12315,12 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", "peerDependencies": { - "zod": "^3.24.1" + "zod": "^3.25 || ^4" } }, "node_modules/zod-validation-error": { diff --git a/package.json b/package.json index 9e5d6955..46f9c0b5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@runloop/rl-cli", - "version": "0.2.0", - "description": "Beautiful CLI for Runloop devbox management", + "version": "0.1.0", + "description": "Beautiful CLI for the Runloop platform", "type": "module", "bin": { "rli": "./dist/cli.js" @@ -21,11 +21,9 @@ "lint": "eslint src --ext .ts,.tsx", "lint:fix": "eslint src --ext .ts,.tsx --fix", "test": "jest", - "test:unit": "jest tests/__tests__/unit", - "test:integration": "jest tests/__tests__/integration", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "test:e2e": "RUN_E2E=1 jest tests/__tests__/integration" + "test:components": "NODE_OPTIONS='--experimental-vm-modules' jest --config jest.components.config.js --coverage --forceExit" }, "keywords": [ "runloop", @@ -89,6 +87,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^6.1.1", "globals": "^16.4.0", + "ink-testing-library": "^4.0.0", "jest": "^29.7.0", "prettier": "^3.6.2", "ts-jest": "^29.1.0", diff --git a/src/cli.ts b/src/cli.ts index 89c71773..d7c26ecf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,26 +7,16 @@ import { deleteDevbox } from "./commands/devbox/delete.js"; import { execCommand } from "./commands/devbox/exec.js"; import { uploadFile } from "./commands/devbox/upload.js"; import { getConfig } from "./utils/config.js"; -import { readFileSync } from "fs"; -import { fileURLToPath } from "url"; -import { dirname, join } from "path"; - -// Get version from package.json -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const packageJson = JSON.parse( - readFileSync(join(__dirname, "../package.json"), "utf8"), -); -export const VERSION = packageJson.version; - +import { VERSION } from "./version.js"; import { exitAlternateScreenBuffer } from "./utils/screen.js"; +import { processUtils } from "./utils/processUtils.js"; // Global Ctrl+C handler to ensure it always exits -process.on("SIGINT", () => { +processUtils.on("SIGINT", () => { // Force exit immediately, clearing alternate screen buffer exitAlternateScreenBuffer(); - process.stdout.write("\n"); - process.exit(130); // Standard exit code for SIGINT + processUtils.stdout.write("\n"); + processUtils.exit(130); // Standard exit code for SIGINT }); const program = new Command(); @@ -69,7 +59,7 @@ config console.error( `\n❌ Invalid theme mode: ${mode}\nValid options: auto, light, dark\n`, ); - process.exit(1); + processUtils.exit(1); } }); @@ -666,7 +656,7 @@ program const config = getConfig(); if (!config.apiKey) { console.error("\n❌ API key not configured. Run: rli auth\n"); - process.exit(1); + processUtils.exit(1); } } diff --git a/src/commands/auth.tsx b/src/commands/auth.tsx index 7aa4422c..5e8d7364 100644 --- a/src/commands/auth.tsx +++ b/src/commands/auth.tsx @@ -7,6 +7,7 @@ import { Banner } from "../components/Banner.js"; import { SuccessMessage } from "../components/SuccessMessage.js"; import { getSettingsUrl } from "../utils/url.js"; import { colors } from "../utils/theme.js"; +import { processUtils } from "../utils/processUtils.js"; const AuthUI = () => { const [apiKey, setApiKeyInput] = React.useState(""); @@ -16,7 +17,7 @@ const AuthUI = () => { if (key.return && apiKey.trim()) { setApiKey(apiKey.trim()); setSaved(true); - setTimeout(() => process.exit(0), 1000); + setTimeout(() => processUtils.exit(0), 1000); } }); diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index a3e75a13..d472b107 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -21,10 +21,11 @@ import { DevboxCreatePage } from "../../components/DevboxCreatePage.js"; import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; import { useViewportHeight } from "../../hooks/useViewportHeight.js"; import { useCursorPagination } from "../../hooks/useCursorPagination.js"; +import { useNavigation } from "../../store/navigationStore.js"; const DEFAULT_PAGE_SIZE = 10; -type OperationType = "create_devbox" | "delete" | null; +type OperationType = "create_devbox" | "delete" | "view_logs" | null; // Local interface for blueprint data used in this component interface BlueprintListItem { @@ -62,6 +63,7 @@ const ListBlueprintsUI = ({ const [showCreateDevbox, setShowCreateDevbox] = React.useState(false); const [selectedIndex, setSelectedIndex] = React.useState(0); const [showPopup, setShowPopup] = React.useState(false); + const { navigate } = useNavigation(); // Calculate overhead for viewport height const overhead = 13; @@ -271,6 +273,14 @@ const ListBlueprintsUI = ({ ): Operation[] => { const operations: Operation[] = []; + // View Logs is always available + operations.push({ + key: "view_logs", + label: "View Logs", + color: colors.info, + icon: figures.info, + }); + if ( blueprint && (blueprint.status === "build_complete" || @@ -312,15 +322,41 @@ const ListBlueprintsUI = ({ const startIndex = currentPage * PAGE_SIZE; const endIndex = startIndex + blueprints.length; - const executeOperation = async () => { + const executeOperation = async ( + blueprintOverride?: BlueprintListItem, + operationOverride?: OperationType, + ) => { const client = getClient(); - const blueprint = selectedBlueprint; + // Use override if provided, otherwise use selectedBlueprint from state + // If neither is available, use selectedBlueprintItem as fallback + const blueprint = + blueprintOverride || selectedBlueprint || selectedBlueprintItem; + // Use operation override if provided (to avoid state timing issues) + const operation = operationOverride || executingOperation; + + if (!blueprint) { + console.error("No blueprint selected for operation"); + return; + } - if (!blueprint) return; + // Ensure selectedBlueprint is set in state if it wasn't already + if (!selectedBlueprint && blueprint) { + setSelectedBlueprint(blueprint); + } try { setOperationLoading(true); - switch (executingOperation) { + switch (operation) { + case "view_logs": + // Navigate to the logs screen + setOperationLoading(false); + setExecutingOperation(null); + navigate("blueprint-logs", { + blueprintId: blueprint.id, + blueprintName: blueprint.name || blueprint.id, + }); + return; + case "create_devbox": setShowCreateDevbox(true); setExecutingOperation(null); @@ -354,15 +390,20 @@ const ListBlueprintsUI = ({ useInput((input, key) => { // Handle operation input mode if (executingOperation && !operationResult && !operationError) { + // Allow escape/q to cancel any operation, even during loading + if (input === "q" || key.escape) { + setExecutingOperation(null); + setOperationInput(""); + setOperationLoading(false); + return; + } + const currentOp = allOperations.find( (op) => op.key === executingOperation, ); if (currentOp?.needsInput) { if (key.return) { executeOperation(); - } else if (input === "q" || key.escape) { - setExecutingOperation(null); - setOperationInput(""); } } return; @@ -403,7 +444,10 @@ const ListBlueprintsUI = ({ } else { setSelectedBlueprint(selectedBlueprintItem); setExecutingOperation(operationKey as OperationType); - executeOperation(); + executeOperation( + selectedBlueprintItem, + operationKey as OperationType, + ); } } else if (key.escape || input === "q") { setShowPopup(false); @@ -426,7 +470,17 @@ const ListBlueprintsUI = ({ setShowPopup(false); setSelectedBlueprint(selectedBlueprintItem); setExecutingOperation("delete"); - executeOperation(); + executeOperation(selectedBlueprintItem, "delete"); + } + } else if (input === "l") { + const logsIndex = allOperations.findIndex( + (op) => op.key === "view_logs", + ); + if (logsIndex >= 0) { + setShowPopup(false); + setSelectedBlueprint(selectedBlueprintItem); + setExecutingOperation("view_logs"); + executeOperation(selectedBlueprintItem, "view_logs"); } } return; @@ -458,6 +512,10 @@ const ListBlueprintsUI = ({ } else if (input === "a") { setShowPopup(true); setSelectedOperation(0); + } else if (input === "l" && selectedBlueprintItem) { + setSelectedBlueprint(selectedBlueprintItem); + setExecutingOperation("view_logs"); + executeOperation(selectedBlueprintItem, "view_logs"); } else if (input === "o" && blueprints[selectedIndex]) { const url = getBlueprintUrl(blueprints[selectedIndex].id); const openBrowser = async () => { @@ -523,6 +581,10 @@ const ListBlueprintsUI = ({ const operationLabel = currentOp?.label || "Operation"; if (operationLoading) { + const messages: Record = { + delete: "Deleting blueprint...", + view_logs: "Fetching logs...", + }; return ( <>
- + + + + Press [q] or [esc] to cancel + + ); } - if (!needsInput) { - const messages: Record = { - delete: "Deleting blueprint...", - }; + // Only show input screen if operation needs input + // Operations like view_logs navigate away and don't need this screen + if (needsInput) { return ( <> -
- +
+ + + + {selectedBlueprint.name || selectedBlueprint.id} + + + + + {currentOp?.inputPrompt || ""}{" "} + + + + + + + + Press [Enter] to execute • [q or esc] Cancel + + + ); } - - return ( - <> - -
- - - - {selectedBlueprint.name || selectedBlueprint.id} - - - - {currentOp.inputPrompt} - - - - - - - Press [Enter] to execute • [q or esc] Cancel - - - - - ); + // For operations that don't need input (like view_logs), fall through to list view } // Create devbox screen @@ -716,7 +771,9 @@ const ListBlueprintsUI = ({ ? "c" : op.key === "delete" ? "d" - : "", + : op.key === "view_logs" + ? "l" + : "", }))} selectedOperation={selectedOperation} onClose={() => setShowPopup(false)} diff --git a/src/commands/config.tsx b/src/commands/config.tsx index c4cadda6..9ffb1812 100644 --- a/src/commands/config.tsx +++ b/src/commands/config.tsx @@ -9,6 +9,7 @@ import { import { Header } from "../components/Header.js"; import { SuccessMessage } from "../components/SuccessMessage.js"; import { colors, getCurrentTheme, setThemeMode } from "../utils/theme.js"; +import { processUtils } from "../utils/processUtils.js"; interface ThemeOption { value: "auto" | "light" | "dark"; @@ -217,9 +218,9 @@ const StaticConfigUI = ({ action, value }: StaticConfigUIProps) => { } setSaved(true); - setTimeout(() => process.exit(0), 1500); + setTimeout(() => processUtils.exit(0), 1500); } else if (action === "get" || !action) { - setTimeout(() => process.exit(0), 2000); + setTimeout(() => processUtils.exit(0), 2000); } }, [action, value]); diff --git a/src/commands/devbox/ssh.ts b/src/commands/devbox/ssh.ts index dd59a220..e6b40030 100644 --- a/src/commands/devbox/ssh.ts +++ b/src/commands/devbox/ssh.ts @@ -5,6 +5,7 @@ import { spawn } from "child_process"; import { getClient } from "../../utils/client.js"; import { output, outputError } from "../../utils/output.js"; +import { processUtils } from "../../utils/processUtils.js"; import { getSSHKey, waitForReady, @@ -98,7 +99,7 @@ export async function sshDevbox(devboxId: string, options: SSHOptions = {}) { }); sshProcess.on("close", (code) => { - process.exit(code || 0); + processUtils.exit(code || 0); }); sshProcess.on("error", (err) => { diff --git a/src/commands/devbox/tunnel.ts b/src/commands/devbox/tunnel.ts index ed90486a..38f81933 100644 --- a/src/commands/devbox/tunnel.ts +++ b/src/commands/devbox/tunnel.ts @@ -5,6 +5,7 @@ import { spawn } from "child_process"; import { getClient } from "../../utils/client.js"; import { output, outputError } from "../../utils/output.js"; +import { processUtils } from "../../utils/processUtils.js"; import { getSSHKey, getProxyCommand, checkSSHTools } from "../../utils/ssh.js"; interface TunnelOptions { @@ -78,7 +79,7 @@ export async function createTunnel(devboxId: string, options: TunnelOptions) { tunnelProcess.on("close", (code) => { console.log("\nTunnel closed."); - process.exit(code || 0); + processUtils.exit(code || 0); }); tunnelProcess.on("error", (err) => { diff --git a/src/commands/mcp-http.ts b/src/commands/mcp-http.ts index 45d66eae..7990fae7 100644 --- a/src/commands/mcp-http.ts +++ b/src/commands/mcp-http.ts @@ -2,6 +2,7 @@ import { spawn } from "child_process"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; +import { processUtils } from "../utils/processUtils.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -10,7 +11,7 @@ export async function startMcpHttpServer(port?: number) { // Get the path to the compiled MCP HTTP server const serverPath = join(__dirname, "../mcp/server-http.js"); - const env = { ...process.env }; + const env = { ...processUtils.env }; if (port) { env.PORT = port.toString(); } @@ -26,20 +27,20 @@ export async function startMcpHttpServer(port?: number) { serverProcess.on("error", (error) => { console.error("Failed to start MCP HTTP server:", error); - process.exit(1); + processUtils.exit(1); }); serverProcess.on("exit", (code) => { if (code !== 0) { console.error(`MCP HTTP server exited with code ${code}`); - process.exit(code || 1); + processUtils.exit(code || 1); } }); // Handle Ctrl+C - process.on("SIGINT", () => { + processUtils.on("SIGINT", () => { console.log("\nShutting down MCP HTTP server..."); serverProcess.kill("SIGINT"); - process.exit(0); + processUtils.exit(0); }); } diff --git a/src/commands/mcp-install.ts b/src/commands/mcp-install.ts index b668b6f6..080e55c1 100644 --- a/src/commands/mcp-install.ts +++ b/src/commands/mcp-install.ts @@ -3,6 +3,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; import { homedir, platform } from "os"; import { join } from "path"; import { execSync } from "child_process"; +import { processUtils } from "../utils/processUtils.js"; function getClaudeConfigPath(): string { const plat = platform(); @@ -16,7 +17,7 @@ function getClaudeConfigPath(): string { "claude_desktop_config.json", ); } else if (plat === "win32") { - const appData = process.env.APPDATA; + const appData = processUtils.env.APPDATA; if (!appData) { throw new Error("APPDATA environment variable not found"); } @@ -67,7 +68,7 @@ export async function installMcpConfig() { console.error( "Please fix the file manually or delete it to create a new one", ); - process.exit(1); + processUtils.exit(1); } } else { console.log("✓ No existing config found, will create new one"); @@ -91,22 +92,22 @@ export async function installMcpConfig() { console.log("\n❓ Do you want to overwrite it? (y/N): "); // For non-interactive mode, just exit - if (process.stdin.isTTY) { + if (processUtils.stdin.isTTY) { const response = await new Promise((resolve) => { - process.stdin.once("data", (data) => { - resolve(data.toString().trim().toLowerCase()); + processUtils.stdin.on("data", (data) => { + resolve((data as Buffer).toString().trim().toLowerCase()); }); }); if (response !== "y" && response !== "yes") { console.log("\n✓ Keeping existing configuration"); - process.exit(0); + processUtils.exit(0); } } else { console.log( "\n✓ Keeping existing configuration (non-interactive mode)", ); - process.exit(0); + processUtils.exit(0); } } @@ -161,6 +162,6 @@ export async function installMcpConfig() { 2, ), ); - process.exit(1); + processUtils.exit(1); } } diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index e4483e0e..204b68a5 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -2,6 +2,7 @@ import { spawn } from "child_process"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; +import { processUtils } from "../utils/processUtils.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -18,19 +19,19 @@ export async function startMcpServer() { serverProcess.on("error", (error) => { console.error("Failed to start MCP server:", error); - process.exit(1); + processUtils.exit(1); }); serverProcess.on("exit", (code) => { if (code !== 0) { console.error(`MCP server exited with code ${code}`); - process.exit(code || 1); + processUtils.exit(code || 1); } }); // Handle Ctrl+C - process.on("SIGINT", () => { + processUtils.on("SIGINT", () => { serverProcess.kill("SIGINT"); - process.exit(0); + processUtils.exit(0); }); } diff --git a/src/commands/menu.tsx b/src/commands/menu.tsx index fbd88f9a..70b48100 100644 --- a/src/commands/menu.tsx +++ b/src/commands/menu.tsx @@ -4,6 +4,7 @@ import { enterAlternateScreenBuffer, exitAlternateScreenBuffer, } from "../utils/screen.js"; +import { processUtils } from "../utils/processUtils.js"; import { Router } from "../router/Router.js"; import { NavigationProvider } from "../store/navigationStore.js"; @@ -57,5 +58,5 @@ export async function runMainMenu( exitAlternateScreenBuffer(); - process.exit(0); + processUtils.exit(0); } diff --git a/src/components/ActionsPopup.tsx b/src/components/ActionsPopup.tsx index ff26f16a..b39db66a 100644 --- a/src/components/ActionsPopup.tsx +++ b/src/components/ActionsPopup.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Box, Text } from "ink"; import figures from "figures"; import chalk from "chalk"; -import { isLightMode } from "../utils/theme.js"; +import { getChalkTextColor, getChalkColor } from "../utils/theme.js"; // Generic resource type - accepts any object with an id and optional name interface ResourceWithId { @@ -47,11 +47,12 @@ export const ActionsPopup = ({ // CRITICAL: Validate all computed widths are positive integers const contentWidth = Math.max(10, maxContentWidth + 4); - // Get background color chalk function - inverted for contrast - // In light mode (light terminal), use black background for popup - // In dark mode (dark terminal), use white background for popup - const bgColor = isLightMode() ? chalk.bgBlack : chalk.bgWhite; - const textColor = isLightMode() ? chalk.white : chalk.black; + // Get background color chalk function - use theme colors to match the theme mode + // In light mode, use light background; in dark mode, use dark background + const popupBgHex = getChalkColor("background"); + const popupTextHex = getChalkColor("text"); + const bgColorFn = chalk.bgHex(popupBgHex); + const textColorFn = chalk.hex(popupTextHex); // Helper to create background lines with proper padding including left/right margins const createBgLine = (styledContent: string, plainContent: string) => { @@ -63,12 +64,14 @@ export const ActionsPopup = ({ ); const rightPadding = " ".repeat(repeatCount); // Apply background to left padding + content + right padding - return bgColor(" " + styledContent + rightPadding + " "); + return bgColorFn(" " + styledContent + rightPadding + " "); }; // Create empty line with full background // CRITICAL: Validate repeat count is positive integer - const emptyLine = bgColor(" ".repeat(Math.max(1, Math.floor(contentWidth)))); + const emptyLine = bgColorFn( + " ".repeat(Math.max(1, Math.floor(contentWidth))), + ); // Create border lines with background and integrated title const title = `${figures.play} Quick Actions`; @@ -84,19 +87,19 @@ export const ActionsPopup = ({ ); // Use theme primary color for borders to match theme - const borderColorFn = isLightMode() ? chalk.cyan : chalk.blue; + const borderColorFn = getChalkTextColor("primary"); - const borderTop = bgColor( + const borderTop = bgColorFn( borderColorFn("╭─" + titleWithSpaces + "─".repeat(remainingDashes) + "╮"), ); // CRITICAL: Validate contentWidth is a positive integer - const borderBottom = bgColor( + const borderBottom = bgColorFn( borderColorFn( "╰" + "─".repeat(Math.max(1, Math.floor(contentWidth))) + "╯", ), ); const borderSide = (content: string) => { - return bgColor(borderColorFn("│") + content + borderColorFn("│")); + return bgColorFn(borderColorFn("│") + content + borderColorFn("│")); }; return ( @@ -120,11 +123,11 @@ export const ActionsPopup = ({ | "yellow" | "magenta" | "cyan"; - const colorFn = chalk[opColor] || textColor; - styledLine = `${textColor(pointer)} ${colorFn(op.icon)} ${colorFn.bold(op.label)} ${textColor(`[${op.shortcut}]`)}`; + const colorFn = chalk[opColor] || textColorFn; + styledLine = `${textColorFn(pointer)} ${colorFn(op.icon)} ${colorFn.bold(op.label)} ${textColorFn(`[${op.shortcut}]`)}`; } else { - // Unselected: gray/dim text for everything - const dimFn = isLightMode() ? chalk.gray : chalk.gray; + // Unselected: use theme's textDim color for dimmed text + const dimFn = getChalkTextColor("textDim"); styledLine = `${dimFn(pointer)} ${dimFn(op.icon)} ${dimFn(op.label)} ${dimFn(`[${op.shortcut}]`)}`; } @@ -139,7 +142,7 @@ export const ActionsPopup = ({ {borderSide( createBgLine( - textColor( + textColorFn( `${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`, ), `${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`, diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 7e3e1f9a..2d8e1cce 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -2,11 +2,18 @@ import React from "react"; import { Box } from "ink"; import BigText from "ink-big-text"; import Gradient from "ink-gradient"; +import { isLightMode } from "../utils/theme.js"; export const Banner = React.memo(() => { + // Use theme-aware gradient colors + // In light mode, use darker/deeper colors for better contrast on light backgrounds + // "teen" has darker colors (blue/purple) that work well on light backgrounds + // In dark mode, use the vibrant "vice" gradient (pink/cyan) that works well on dark backgrounds + const gradientName = isLightMode() ? "teen" : "vice"; + return ( - - + + diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx index 3a859d58..7bc57862 100644 --- a/src/components/Breadcrumb.tsx +++ b/src/components/Breadcrumb.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Box, Text } from "ink"; import { colors } from "../utils/theme.js"; +import { UpdateNotification } from "./UpdateNotification.js"; export interface BreadcrumbItem { label: string; @@ -9,14 +10,18 @@ export interface BreadcrumbItem { interface BreadcrumbProps { items: BreadcrumbItem[]; + showVersionCheck?: boolean; } -export const Breadcrumb = ({ items }: BreadcrumbProps) => { +export const Breadcrumb = ({ + items, + showVersionCheck = false, +}: BreadcrumbProps) => { const env = process.env.RUNLOOP_ENV?.toLowerCase(); const isDevEnvironment = env === "dev"; return ( - + { ); })} + {showVersionCheck && ( + + + + )} ); }; diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index ee5cf06b..a0fd0940 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -22,7 +22,7 @@ import { createTunnel, createSSHKey, } from "../services/devboxService.js"; -import { parseLogEntry } from "../utils/logFormatter.js"; +import { LogsViewer } from "./LogsViewer.js"; type Operation = | "exec" @@ -71,8 +71,6 @@ export const DevboxActionsMenu = ({ const [operationError, setOperationError] = React.useState( null, ); - const [logsWrapMode, setLogsWrapMode] = React.useState(false); - const [logsScroll, setLogsScroll] = React.useState(0); const [execScroll, setExecScroll] = React.useState(0); const [copyStatus, setCopyStatus] = React.useState(null); @@ -86,15 +84,6 @@ export const DevboxActionsMenu = ({ // Total: 16 lines const execViewport = useViewportHeight({ overhead: 16, minHeight: 10 }); - // Calculate viewport for logs output: - // - Breadcrumb (3 lines + marginBottom): 4 lines - // - Log box borders: 2 lines - // - Stats bar (marginTop + content): 2 lines - // - Help bar (marginTop + content): 2 lines - // - Safety buffer: 1 line - // Total: 11 lines - const logsViewport = useViewportHeight({ overhead: 11, minHeight: 10 }); - // CRITICAL: Aggressive memory cleanup to prevent heap exhaustion React.useEffect(() => { // Clear large data immediately when results are shown to free memory faster @@ -248,8 +237,6 @@ export const DevboxActionsMenu = ({ setOperationResult(null); setOperationError(null); setOperationInput(""); - setLogsWrapMode(true); - setLogsScroll(0); setExecScroll(0); setCopyStatus(null); @@ -360,118 +347,6 @@ export const DevboxActionsMenu = ({ }; copyToClipboard(output); - } else if ( - (key.upArrow || input === "k") && - operationResult && - typeof operationResult === "object" && - (operationResult as any).__customRender === "logs" - ) { - setLogsScroll(Math.max(0, logsScroll - 1)); - } else if ( - (key.downArrow || input === "j") && - operationResult && - typeof operationResult === "object" && - (operationResult as any).__customRender === "logs" - ) { - setLogsScroll(logsScroll + 1); - } else if ( - key.pageUp && - operationResult && - typeof operationResult === "object" && - (operationResult as any).__customRender === "logs" - ) { - setLogsScroll(Math.max(0, logsScroll - 10)); - } else if ( - key.pageDown && - operationResult && - typeof operationResult === "object" && - (operationResult as any).__customRender === "logs" - ) { - setLogsScroll(logsScroll + 10); - } else if ( - input === "g" && - operationResult && - typeof operationResult === "object" && - (operationResult as any).__customRender === "logs" - ) { - setLogsScroll(0); - } else if ( - input === "G" && - operationResult && - typeof operationResult === "object" && - (operationResult as any).__customRender === "logs" - ) { - const logs = (operationResult as any).__logs || []; - const maxScroll = Math.max( - 0, - logs.length - logsViewport.viewportHeight, - ); - setLogsScroll(maxScroll); - } else if ( - input === "w" && - operationResult && - typeof operationResult === "object" && - (operationResult as any).__customRender === "logs" - ) { - setLogsWrapMode(!logsWrapMode); - } else if ( - input === "c" && - operationResult && - typeof operationResult === "object" && - (operationResult as any).__customRender === "logs" - ) { - // Copy logs to clipboard using shared formatter - const logs = (operationResult as any).__logs || []; - const logsText = logs - .map((log: any) => { - const parts = parseLogEntry(log); - const cmd = parts.cmd ? `$ ${parts.cmd} ` : ""; - const exitCode = - parts.exitCode !== null ? `exit=${parts.exitCode} ` : ""; - const shell = parts.shellName ? `(${parts.shellName}) ` : ""; - return `${parts.timestamp} ${parts.level} [${parts.source}] ${shell}${cmd}${parts.message} ${exitCode}`.trim(); - }) - .join("\n"); - - const copyToClipboard = async (text: string) => { - const { spawn } = await import("child_process"); - const platform = process.platform; - - let command: string; - let args: string[]; - - if (platform === "darwin") { - command = "pbcopy"; - args = []; - } else if (platform === "win32") { - command = "clip"; - args = []; - } else { - command = "xclip"; - args = ["-selection", "clipboard"]; - } - - const proc = spawn(command, args); - proc.stdin.write(text); - proc.stdin.end(); - - proc.on("exit", (code) => { - if (code === 0) { - setCopyStatus("Copied to clipboard!"); - setTimeout(() => setCopyStatus(null), 2000); - } else { - setCopyStatus("Failed to copy"); - setTimeout(() => setCopyStatus(null), 2000); - } - }); - - proc.on("error", () => { - setCopyStatus("Copy not supported"); - setTimeout(() => setCopyStatus(null), 2000); - }); - }; - - copyToClipboard(logsText); } return; } @@ -817,226 +692,29 @@ export const DevboxActionsMenu = ({ (operationResult as any).__customRender === "logs" ) { const logs = (operationResult as any).__logs || []; - const totalCount = (operationResult as any).__totalCount || 0; - - const viewportHeight = logsViewport.viewportHeight; - const terminalWidth = logsViewport.terminalWidth; - const maxScroll = Math.max(0, logs.length - viewportHeight); - const actualScroll = Math.min(logsScroll, maxScroll); - const visibleLogs = logs.slice( - actualScroll, - actualScroll + viewportHeight, - ); - const hasMore = actualScroll + viewportHeight < logs.length; - const hasLess = actualScroll > 0; - return ( - <> - - - - {visibleLogs.map((log: any, index: number) => { - const parts = parseLogEntry(log); - - // Sanitize message: escape special chars to prevent layout breaks - const escapedMessage = parts.message - .replace(/\r\n/g, "\\n") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/\t/g, "\\t"); - - // Limit message length to prevent Yoga layout engine errors - const MAX_MESSAGE_LENGTH = 1000; - const fullMessage = - escapedMessage.length > MAX_MESSAGE_LENGTH - ? escapedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..." - : escapedMessage; - - const cmd = parts.cmd - ? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} ` - : ""; - const exitCode = - parts.exitCode !== null ? `exit=${parts.exitCode} ` : ""; - - // Map color names to theme colors - const levelColorMap: Record = { - red: colors.error, - yellow: colors.warning, - blue: colors.primary, - gray: colors.textDim, - }; - const sourceColorMap: Record = { - magenta: "#d33682", - cyan: colors.info, - green: colors.success, - yellow: colors.warning, - gray: colors.textDim, - white: colors.text, - }; - const levelColor = - levelColorMap[parts.levelColor] || colors.textDim; - const sourceColor = - sourceColorMap[parts.sourceColor] || colors.textDim; - - if (logsWrapMode) { - return ( - - - {parts.timestamp} - - - - {parts.level} - - - [{parts.source}] - - {parts.shellName && ( - - ({parts.shellName}){" "} - - )} - {cmd && {cmd}} - {fullMessage} - {exitCode && ( - - {" "} - {exitCode} - - )} - - ); - } else { - // Calculate available width for message truncation - const timestampLen = parts.timestamp.length; - const levelLen = parts.level.length; - const sourceLen = parts.source.length + 2; // brackets - const shellLen = parts.shellName - ? parts.shellName.length + 3 - : 0; - const cmdLen = cmd.length; - const exitLen = exitCode.length; - const spacesLen = 5; // spaces between elements - const metadataWidth = - timestampLen + - levelLen + - sourceLen + - shellLen + - cmdLen + - exitLen + - spacesLen; - - const safeTerminalWidth = Math.max(80, terminalWidth); - const availableMessageWidth = Math.max( - 20, - safeTerminalWidth - metadataWidth, - ); - const truncatedMessage = - fullMessage.length > availableMessageWidth - ? fullMessage.substring( - 0, - Math.max(1, availableMessageWidth - 3), - ) + "..." - : fullMessage; - - return ( - - - {parts.timestamp} - - - - {parts.level} - - - [{parts.source}] - - {parts.shellName && ( - - ({parts.shellName}){" "} - - )} - {cmd && {cmd}} - {truncatedMessage} - {exitCode && ( - - {" "} - {exitCode} - - )} - - ); - } - })} - - - - - {figures.hamburger} {totalCount} - - - {" "} - total logs - - - {" "} - •{" "} - - - Viewing {actualScroll + 1}- - {Math.min(actualScroll + viewportHeight, logs.length)} of{" "} - {logs.length} - - {hasLess && {figures.arrowUp}} - {hasMore && ( - {figures.arrowDown} - )} - - {" "} - •{" "} - - - {logsWrapMode ? "Wrap: ON" : "Wrap: OFF"} - - {copyStatus && ( - <> - - {" "} - •{" "} - - - {copyStatus} - - - )} - - - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [g] Top • [G] Bottom • [w] Toggle - Wrap • [c] Copy • [Enter], [q], or [esc] Back - - - + { + // Clear large data structures immediately to prevent memory leaks + setOperationResult(null); + setOperationError(null); + setOperationInput(""); + + // If skipOperationsMenu is true, go back to parent instead of operations menu + if (skipOperationsMenu) { + setExecutingOperation(null); + onBack(); + } else { + setExecutingOperation(null); + } + }} + title="Logs" + /> ); } diff --git a/src/components/InteractiveSpawn.tsx b/src/components/InteractiveSpawn.tsx index 8c3273f2..f40b9de7 100644 --- a/src/components/InteractiveSpawn.tsx +++ b/src/components/InteractiveSpawn.tsx @@ -6,9 +6,11 @@ import React from "react"; import { spawn, ChildProcess } from "child_process"; import { - exitAlternateScreenBuffer, + showCursor, + clearScreen, enterAlternateScreenBuffer, } from "../utils/screen.js"; +import { processUtils } from "../utils/processUtils.js"; interface InteractiveSpawnProps { command: string; @@ -27,8 +29,21 @@ function releaseTerminal(): void { // Disable raw mode so the subprocess can control terminal echo and line buffering // SSH needs to set its own terminal modes - if (process.stdin.isTTY && process.stdin.setRawMode) { - process.stdin.setRawMode(false); + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(false); + } + + // Reset terminal attributes (SGR reset) - clears any colors/styles Ink may have set + if (processUtils.stdout.isTTY) { + processUtils.stdout.write("\x1b[0m"); + } + + // Show cursor - Ink may have hidden it, and subprocesses expect it to be visible + showCursor(); + + // Flush stdout to ensure all pending writes are complete before handoff + if (processUtils.stdout.isTTY) { + processUtils.stdout.write(""); } } @@ -36,9 +51,15 @@ function releaseTerminal(): void { * Restores terminal control to Ink after subprocess exits. */ function restoreTerminal(): void { + // Clear the screen to remove subprocess output before Ink renders + clearScreen(); + + // Re-enter alternate screen buffer for Ink's fullscreen UI + enterAlternateScreenBuffer(); + // Re-enable raw mode for Ink's input handling - if (process.stdin.isTTY && process.stdin.setRawMode) { - process.stdin.setRawMode(true); + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(true); } // Resume stdin so Ink can read input again @@ -64,14 +85,12 @@ export const InteractiveSpawn: React.FC = ({ } hasSpawnedRef.current = true; - // Exit alternate screen so subprocess gets a clean terminal - exitAlternateScreenBuffer(); - // Release terminal from Ink's control releaseTerminal(); - // Small delay to ensure terminal state is fully released - setTimeout(() => { + // Use setImmediate to ensure terminal state is released without noticeable delay + // This is faster than setTimeout and ensures the event loop has processed the release + setImmediate(() => { // Spawn the process with inherited stdio for proper TTY allocation const child = spawn(command, args, { stdio: "inherit", // This allows the process to use the terminal directly @@ -88,9 +107,6 @@ export const InteractiveSpawn: React.FC = ({ // Restore terminal control to Ink restoreTerminal(); - // Re-enter alternate screen after process exits - enterAlternateScreenBuffer(); - if (onExit) { onExit(code); } @@ -104,14 +120,11 @@ export const InteractiveSpawn: React.FC = ({ // Restore terminal control to Ink restoreTerminal(); - // Re-enter alternate screen on error - enterAlternateScreenBuffer(); - if (onError) { onError(error); } }); - }, 50); + }); // Cleanup function - kill the process if component unmounts return () => { diff --git a/src/components/LogsViewer.tsx b/src/components/LogsViewer.tsx new file mode 100644 index 00000000..550545c1 --- /dev/null +++ b/src/components/LogsViewer.tsx @@ -0,0 +1,329 @@ +/** + * LogsViewer - Shared component for viewing logs (devbox or blueprint) + * Extracted from DevboxActionsMenu for reuse + */ +import React from "react"; +import { Box, Text, useInput } from "ink"; +import figures from "figures"; +import { Breadcrumb } from "./Breadcrumb.js"; +import { colors } from "../utils/theme.js"; +import { useViewportHeight } from "../hooks/useViewportHeight.js"; +import { parseAnyLogEntry, type AnyLog } from "../utils/logFormatter.js"; + +interface LogsViewerProps { + logs: AnyLog[]; + breadcrumbItems?: Array<{ label: string; active?: boolean }>; + onBack: () => void; + title?: string; +} + +export const LogsViewer = ({ + logs, + breadcrumbItems = [{ label: "Logs", active: true }], + onBack, + title = "Logs", +}: LogsViewerProps) => { + const [logsWrapMode, setLogsWrapMode] = React.useState(false); + const [logsScroll, setLogsScroll] = React.useState(0); + const [copyStatus, setCopyStatus] = React.useState(null); + + // Calculate viewport for logs output: + // - Breadcrumb (3 lines + marginBottom): 4 lines + // - Log box borders: 2 lines + // - Stats bar (marginTop + content): 2 lines + // - Help bar (marginTop + content): 2 lines + // - Safety buffer: 1 line + // Total: 11 lines + const logsViewport = useViewportHeight({ overhead: 11, minHeight: 10 }); + + // Handle input for logs navigation + useInput((input, key) => { + if (key.upArrow || input === "k") { + setLogsScroll(Math.max(0, logsScroll - 1)); + } else if (key.downArrow || input === "j") { + setLogsScroll(logsScroll + 1); + } else if (key.pageUp) { + setLogsScroll(Math.max(0, logsScroll - 10)); + } else if (key.pageDown) { + setLogsScroll(logsScroll + 10); + } else if (input === "g") { + setLogsScroll(0); + } else if (input === "G") { + const maxScroll = Math.max(0, logs.length - logsViewport.viewportHeight); + setLogsScroll(maxScroll); + } else if (input === "w") { + setLogsWrapMode(!logsWrapMode); + } else if (input === "c") { + // Copy logs to clipboard using shared formatter + const logsText = logs + .map((log: AnyLog) => { + const parts = parseAnyLogEntry(log); + const cmd = parts.cmd ? `$ ${parts.cmd} ` : ""; + const exitCode = + parts.exitCode !== null ? `exit=${parts.exitCode} ` : ""; + const shell = parts.shellName ? `(${parts.shellName}) ` : ""; + return `${parts.timestamp} ${parts.level} [${parts.source}] ${shell}${cmd}${parts.message} ${exitCode}`.trim(); + }) + .join("\n"); + + const copyToClipboard = async (text: string) => { + const { spawn } = await import("child_process"); + const platform = process.platform; + + let command: string; + let args: string[]; + + if (platform === "darwin") { + command = "pbcopy"; + args = []; + } else if (platform === "win32") { + command = "clip"; + args = []; + } else { + command = "xclip"; + args = ["-selection", "clipboard"]; + } + + const proc = spawn(command, args); + proc.stdin.write(text); + proc.stdin.end(); + + proc.on("exit", (code) => { + if (code === 0) { + setCopyStatus("Copied to clipboard!"); + setTimeout(() => setCopyStatus(null), 2000); + } else { + setCopyStatus("Failed to copy"); + setTimeout(() => setCopyStatus(null), 2000); + } + }); + + proc.on("error", () => { + setCopyStatus("Copy not supported"); + setTimeout(() => setCopyStatus(null), 2000); + }); + }; + + copyToClipboard(logsText); + } else if (input === "q" || key.escape || key.return) { + onBack(); + } + }); + + const viewportHeight = Math.max(1, logsViewport.viewportHeight); + const terminalWidth = logsViewport.terminalWidth; + const maxScroll = Math.max(0, logs.length - viewportHeight); + const actualScroll = Math.min(logsScroll, maxScroll); + const visibleLogs = logs.slice(actualScroll, actualScroll + viewportHeight); + const hasMore = actualScroll + viewportHeight < logs.length; + const hasLess = actualScroll > 0; + + return ( + <> + + + + {logs.length === 0 ? ( + + No logs available + + ) : ( + visibleLogs.map((log: AnyLog, index: number) => { + const parts = parseAnyLogEntry(log); + + // Sanitize message: escape special chars to prevent layout breaks + const escapedMessage = parts.message + .replace(/\r\n/g, "\\n") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); + + // Limit message length to prevent Yoga layout engine errors + const MAX_MESSAGE_LENGTH = 1000; + const fullMessage = + escapedMessage.length > MAX_MESSAGE_LENGTH + ? escapedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..." + : escapedMessage; + + const cmd = parts.cmd + ? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} ` + : ""; + const exitCode = + parts.exitCode !== null ? `exit=${parts.exitCode} ` : ""; + + // Map color names to theme colors + const levelColorMap: Record = { + red: colors.error, + yellow: colors.warning, + blue: colors.primary, + gray: colors.textDim, + }; + const sourceColorMap: Record = { + magenta: "#d33682", + cyan: colors.info, + green: colors.success, + yellow: colors.warning, + gray: colors.textDim, + white: colors.text, + }; + const levelColor = + levelColorMap[parts.levelColor] || colors.textDim; + const sourceColor = + sourceColorMap[parts.sourceColor] || colors.textDim; + + if (logsWrapMode) { + return ( + + + {parts.timestamp} + + + + {parts.level} + + + [{parts.source}] + + {parts.shellName && ( + + ({parts.shellName}){" "} + + )} + {cmd && {cmd}} + {fullMessage} + {exitCode && ( + + {" "} + {exitCode} + + )} + + ); + } else { + // Calculate available width for message truncation + const timestampLen = parts.timestamp.length; + const levelLen = parts.level.length; + const sourceLen = parts.source.length + 2; // brackets + const shellLen = parts.shellName ? parts.shellName.length + 3 : 0; + const cmdLen = cmd.length; + const exitLen = exitCode.length; + const spacesLen = 5; // spaces between elements + const metadataWidth = + timestampLen + + levelLen + + sourceLen + + shellLen + + cmdLen + + exitLen + + spacesLen; + + const safeTerminalWidth = Math.max(80, terminalWidth); + const availableMessageWidth = Math.max( + 20, + safeTerminalWidth - metadataWidth, + ); + const truncatedMessage = + fullMessage.length > availableMessageWidth + ? fullMessage.substring( + 0, + Math.max(1, availableMessageWidth - 3), + ) + "..." + : fullMessage; + + return ( + + + {parts.timestamp} + + + + {parts.level} + + + [{parts.source}] + + {parts.shellName && ( + + ({parts.shellName}){" "} + + )} + {cmd && {cmd}} + {truncatedMessage} + {exitCode && ( + + {" "} + {exitCode} + + )} + + ); + } + }) + )} + + + + + {figures.hamburger} {logs.length} + + + {" "} + total logs + + + {" "} + •{" "} + + + Viewing {actualScroll + 1}- + {Math.min(actualScroll + viewportHeight, logs.length)} of{" "} + {logs.length} + + {hasLess && {figures.arrowUp}} + {hasMore && {figures.arrowDown}} + + {" "} + •{" "} + + + {logsWrapMode ? "Wrap: ON" : "Wrap: OFF"} + + {copyStatus && ( + <> + + {" "} + •{" "} + + + {copyStatus} + + + )} + + + + + {figures.arrowUp} + {figures.arrowDown} Navigate • [g] Top • [G] Bottom • [w] Toggle Wrap + • [c] Copy • [Enter], [q], or [esc] Back + + + + ); +}; diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 44e2d2ee..ca9a1672 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -3,7 +3,7 @@ import { Box, Text, useInput, useApp } from "ink"; import figures from "figures"; import { Banner } from "./Banner.js"; import { Breadcrumb } from "./Breadcrumb.js"; -import { VERSION } from "../cli.js"; +import { VERSION } from "../version.js"; import { colors } from "../utils/theme.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; @@ -133,7 +133,10 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { return ( - + diff --git a/src/components/UpdateNotification.tsx b/src/components/UpdateNotification.tsx new file mode 100644 index 00000000..4c76931c --- /dev/null +++ b/src/components/UpdateNotification.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { colors } from "../utils/theme.js"; +import { VERSION } from "../version.js"; + +/** + * Version check component that checks npm for updates and displays a notification + * Restored from git history and enhanced with better visual styling + */ +export const UpdateNotification: React.FC = () => { + const [updateAvailable, setUpdateAvailable] = React.useState( + null, + ); + const [isChecking, setIsChecking] = React.useState(true); + + React.useEffect(() => { + const checkForUpdates = async () => { + try { + const currentVersion = VERSION; + const response = await fetch( + "https://registry.npmjs.org/@runloop/rl-cli/latest", + ); + + if (response.ok) { + const data = (await response.json()) as { version: string }; + const latestVersion = data.version; + + if (latestVersion && latestVersion !== currentVersion) { + // Check if current version is older than latest + const compareVersions = ( + version1: string, + version2: string, + ): number => { + const v1parts = version1.split(".").map(Number); + const v2parts = version2.split(".").map(Number); + + for ( + let i = 0; + i < Math.max(v1parts.length, v2parts.length); + i++ + ) { + const v1part = v1parts[i] || 0; + const v2part = v2parts[i] || 0; + + if (v1part > v2part) return 1; + if (v1part < v2part) return -1; + } + + return 0; + }; + + const isUpdateAvailable = + compareVersions(latestVersion, currentVersion) > 0; + + if (isUpdateAvailable) { + setUpdateAvailable(latestVersion); + } + } + } + } catch { + // Silently fail + } finally { + setIsChecking(false); + } + }; + + checkForUpdates(); + }, []); + + if (isChecking || !updateAvailable) { + return null; + } + + return ( + + + ✨ + + + {" "} + Update available:{" "} + + + {VERSION} + + + {" "} + →{" "} + + + {updateAvailable} + + + {" "} + • Run:{" "} + + + npm install -g @runloop/rl-cli@latest + + + ); +}; diff --git a/src/hooks/useExitOnCtrlC.ts b/src/hooks/useExitOnCtrlC.ts index f519c984..4c1f97c5 100644 --- a/src/hooks/useExitOnCtrlC.ts +++ b/src/hooks/useExitOnCtrlC.ts @@ -4,12 +4,13 @@ */ import { useInput } from "ink"; import { exitAlternateScreenBuffer } from "../utils/screen.js"; +import { processUtils } from "../utils/processUtils.js"; export function useExitOnCtrlC(): void { useInput((input, key) => { if (key.ctrl && input === "c") { exitAlternateScreenBuffer(); - process.exit(130); // Standard exit code for SIGINT + processUtils.exit(130); // Standard exit code for SIGINT } }); } diff --git a/src/mcp/server-http.ts b/src/mcp/server-http.ts index c18739e9..7416b39b 100644 --- a/src/mcp/server-http.ts +++ b/src/mcp/server-http.ts @@ -8,6 +8,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { getClient } from "../utils/client.js"; import express from "express"; +import { processUtils } from "../utils/processUtils.js"; // Define available tools for the MCP server const TOOLS: Tool[] = [ @@ -449,5 +450,5 @@ async function main() { main().catch((error) => { console.error("Fatal error in main():", error); - process.exit(1); + processUtils.exit(1); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 7eee8308..4ae89b3c 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -7,7 +7,9 @@ import { Tool, } from "@modelcontextprotocol/sdk/types.js"; import Runloop from "@runloop/api-client"; +import { VERSION } from "@runloop/api-client/version.js"; import Conf from "conf"; +import { processUtils } from "../utils/processUtils.js"; // Client configuration interface Config { @@ -71,6 +73,9 @@ function getClient(): Runloop { baseURL, timeout: 10000, // 10 seconds instead of default 30 seconds maxRetries: 2, // 2 retries instead of default 5 (only for retryable errors) + defaultHeaders: { + "User-Agent": `Runloop/JS ${VERSION} - CLI MCP`, + }, }); } @@ -505,5 +510,5 @@ main().catch((error) => { "[MCP] Stack trace:", error instanceof Error ? error.stack : "N/A", ); - process.exit(1); + processUtils.exit(1); }); diff --git a/src/router/Router.tsx b/src/router/Router.tsx index e1eb4058..c9de0025 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -17,6 +17,7 @@ import { DevboxDetailScreen } from "../screens/DevboxDetailScreen.js"; import { DevboxActionsScreen } from "../screens/DevboxActionsScreen.js"; import { DevboxCreateScreen } from "../screens/DevboxCreateScreen.js"; import { BlueprintListScreen } from "../screens/BlueprintListScreen.js"; +import { BlueprintLogsScreen } from "../screens/BlueprintLogsScreen.js"; import { SnapshotListScreen } from "../screens/SnapshotListScreen.js"; import { SSHSessionScreen } from "../screens/SSHSessionScreen.js"; @@ -51,6 +52,7 @@ export function Router() { case "blueprint-list": case "blueprint-detail": + case "blueprint-logs": if (!currentScreen.startsWith("blueprint")) { useBlueprintStore.getState().clearAll(); } @@ -95,6 +97,9 @@ export function Router() { {currentScreen === "blueprint-detail" && ( )} + {currentScreen === "blueprint-logs" && ( + + )} {currentScreen === "snapshot-list" && ( )} diff --git a/src/screens/BlueprintLogsScreen.tsx b/src/screens/BlueprintLogsScreen.tsx new file mode 100644 index 00000000..13d5ee04 --- /dev/null +++ b/src/screens/BlueprintLogsScreen.tsx @@ -0,0 +1,116 @@ +/** + * BlueprintLogsScreen - Screen for viewing blueprint build logs + */ +import React from "react"; +import { Box, Text } from "ink"; +import { useNavigation } from "../store/navigationStore.js"; +import { LogsViewer } from "../components/LogsViewer.js"; +import { Header } from "../components/Header.js"; +import { SpinnerComponent } from "../components/Spinner.js"; +import { Breadcrumb } from "../components/Breadcrumb.js"; +import { ErrorMessage } from "../components/ErrorMessage.js"; +import { getBlueprintLogs } from "../services/blueprintService.js"; +import { colors } from "../utils/theme.js"; + +interface BlueprintLogsScreenProps { + blueprintId?: string; +} + +export function BlueprintLogsScreen({ blueprintId }: BlueprintLogsScreenProps) { + const { goBack, params } = useNavigation(); + const [logs, setLogs] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + // Use blueprintId from props or params + const id = blueprintId || (params.blueprintId as string); + + React.useEffect(() => { + if (!id) { + goBack(); + return; + } + + let cancelled = false; + + const fetchLogs = async () => { + try { + setLoading(true); + setError(null); + const blueprintLogs = await getBlueprintLogs(id); + if (!cancelled) { + setLogs(Array.isArray(blueprintLogs) ? blueprintLogs : []); + setLoading(false); + } + } catch (err) { + if (!cancelled) { + setError(err as Error); + setLoading(false); + } + } + }; + + fetchLogs(); + + return () => { + cancelled = true; + }; + }, [id, goBack]); + + if (!id) { + return null; + } + + // Get blueprint name from params if available (for breadcrumb) + const blueprintName = (params.blueprintName as string) || id; + + if (loading) { + return ( + <> + +
+ + + ); + } + + if (error) { + return ( + <> + +
+ + + + Press [q] or [esc] to go back + + + + ); + } + + return ( + + ); +} diff --git a/src/services/blueprintService.ts b/src/services/blueprintService.ts index a0e3e437..b4ead8e9 100644 --- a/src/services/blueprintService.ts +++ b/src/services/blueprintService.ts @@ -106,38 +106,33 @@ export async function getBlueprint(id: string): Promise { /** * Get blueprint logs + * Returns the raw logs array from the API response + * Similar to getDevboxLogs - formatting is handled by logFormatter */ export async function getBlueprintLogs(id: string): Promise { const client = getClient(); const response = await client.blueprints.logs(id); - // CRITICAL: Truncate all strings to prevent Yoga crashes - const MAX_MESSAGE_LENGTH = 1000; - const MAX_LEVEL_LENGTH = 20; - - const logs: any[] = []; + // Return the logs array directly - formatting is handled by logFormatter + // Ensure timestamp_ms is present (API may return timestamp or timestamp_ms) if (response.logs && Array.isArray(response.logs)) { - response.logs.forEach((log: any) => { - // Truncate message and escape newlines - let message = String(log.message || ""); - if (message.length > MAX_MESSAGE_LENGTH) { - message = message.substring(0, MAX_MESSAGE_LENGTH) + "..."; + return response.logs.map((log: any) => { + // Normalize timestamp field to timestamp_ms if needed + // Create a new object to avoid mutating the original + const normalizedLog = { ...log }; + if (normalizedLog.timestamp && !normalizedLog.timestamp_ms) { + // If timestamp is a number, use it directly; if it's a string, parse it + if (typeof normalizedLog.timestamp === "number") { + normalizedLog.timestamp_ms = normalizedLog.timestamp; + } else if (typeof normalizedLog.timestamp === "string") { + normalizedLog.timestamp_ms = new Date( + normalizedLog.timestamp, + ).getTime(); + } } - message = message - .replace(/\r\n/g, "\\n") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/\t/g, "\\t"); - - logs.push({ - timestamp: log.timestamp, - message, - level: log.level - ? String(log.level).substring(0, MAX_LEVEL_LENGTH) - : undefined, - }); + return normalizedLog; }); } - return logs; + return []; } diff --git a/src/store/navigationStore.tsx b/src/store/navigationStore.tsx index 256f7fd3..5e31870a 100644 --- a/src/store/navigationStore.tsx +++ b/src/store/navigationStore.tsx @@ -8,6 +8,7 @@ export type ScreenName = | "devbox-create" | "blueprint-list" | "blueprint-detail" + | "blueprint-logs" | "snapshot-list" | "snapshot-detail" | "ssh-session"; @@ -15,6 +16,7 @@ export type ScreenName = export interface RouteParams { devboxId?: string; blueprintId?: string; + blueprintName?: string; snapshotId?: string; operation?: string; focusDevboxId?: string; diff --git a/src/utils/client.ts b/src/utils/client.ts index 75e52dd6..6d7a1748 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -1,4 +1,5 @@ import Runloop from "@runloop/api-client"; +import { VERSION } from "@runloop/api-client/version.js"; import { getConfig } from "./config.js"; /** @@ -30,5 +31,8 @@ export function getClient(): Runloop { return new Runloop({ bearerToken: config.apiKey, baseURL, + defaultHeaders: { + "User-Agent": `Runloop/JS ${VERSION} - CLI`, + }, }); } diff --git a/src/utils/logFormatter.ts b/src/utils/logFormatter.ts index 2a2846de..6dc4ac8d 100644 --- a/src/utils/logFormatter.ts +++ b/src/utils/logFormatter.ts @@ -4,8 +4,13 @@ import chalk from "chalk"; import type { DevboxLogsListView } from "@runloop/api-client/resources/devboxes/logs"; +import type { BlueprintBuildLog } from "@runloop/api-client/resources/blueprints"; export type DevboxLog = DevboxLogsListView["logs"][number]; +export type BlueprintLog = BlueprintBuildLog; + +// Union type for both log types +export type AnyLog = DevboxLog | BlueprintLog; // Source abbreviations for consistent display const SOURCE_CONFIG: Record = { @@ -116,7 +121,7 @@ export function getSourceInfo(source: string | null | undefined): { } /** - * Parse a log entry into formatted parts (for use in Ink UI) + * Parse a devbox log entry into formatted parts (for use in Ink UI) */ export function parseLogEntry(log: DevboxLog): FormattedLogParts { const levelInfo = getLogLevelInfo(log.level); @@ -136,6 +141,53 @@ export function parseLogEntry(log: DevboxLog): FormattedLogParts { }; } +/** + * Parse a blueprint log entry into formatted parts (for use in Ink UI) + * Blueprint logs have a simpler structure (no source, shell_name, cmd, exit_code) + */ +export function parseBlueprintLogEntry(log: BlueprintLog): FormattedLogParts { + const levelInfo = getLogLevelInfo(log.level); + // Blueprint logs don't have a source, use "build" as default + const sourceInfo = getSourceInfo("build"); + + // Handle timestamp - may be timestamp_ms or timestamp + let timestampMs: number; + if (log.timestamp_ms !== undefined) { + timestampMs = log.timestamp_ms; + } else if ((log as any).timestamp !== undefined) { + const ts = (log as any).timestamp; + timestampMs = typeof ts === "number" ? ts : new Date(ts).getTime(); + } else { + // Fallback to current time if no timestamp + timestampMs = Date.now(); + } + + return { + timestamp: formatTimestamp(timestampMs), + level: levelInfo.name, + levelColor: levelInfo.color, + source: sourceInfo.abbrev, + sourceColor: sourceInfo.color, + shellName: null, + cmd: null, + message: log.message || "", + exitCode: null, + exitCodeColor: "gray", + }; +} + +/** + * Parse any log entry (devbox or blueprint) into formatted parts + */ +export function parseAnyLogEntry(log: AnyLog): FormattedLogParts { + // Check if it's a devbox log by looking for source field + if ("source" in log || "shell_name" in log || "cmd" in log) { + return parseLogEntry(log as DevboxLog); + } else { + return parseBlueprintLogEntry(log as BlueprintLog); + } +} + /** * Format a log entry as a string with chalk colors (for CLI output) */ diff --git a/src/utils/output.ts b/src/utils/output.ts index 52e2eaa0..b407db60 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -7,6 +7,7 @@ */ import YAML from "yaml"; +import { processUtils } from "./processUtils.js"; export type OutputFormat = "text" | "json" | "yaml"; @@ -37,7 +38,7 @@ function resolveFormat(options: SimpleOutputOptions): OutputFormat { console.error( `Unknown output format: ${format}. Valid options: text, json, yaml`, ); - process.exit(1); + processUtils.exit(1); } /** @@ -154,7 +155,7 @@ export function outputError(message: string, error?: Error | unknown): never { if (error && errorMessage !== message) { console.error(` ${errorMessage}`); } - process.exit(1); + processUtils.exit(1); } /** @@ -269,5 +270,5 @@ export function validateOutputFormat(format?: string): OutputFormat { console.error( `Unknown output format: ${format}. Valid options: text, json, yaml`, ); - process.exit(1); + processUtils.exit(1); } diff --git a/src/utils/processUtils.ts b/src/utils/processUtils.ts new file mode 100644 index 00000000..a9001d9f --- /dev/null +++ b/src/utils/processUtils.ts @@ -0,0 +1,203 @@ +/** + * Process utilities wrapper for testability. + * + * This module provides a mockable interface for process-related operations + * like exit, stdout/stderr writes, and terminal detection. In tests, you can + * replace these functions with mocks to avoid actual process termination + * and capture output. + * + * Usage in code: + * import { processUtils } from '../utils/processUtils.js'; + * processUtils.exit(1); + * processUtils.stdout.write('Hello'); + * + * Usage in tests: + * import { processUtils, resetProcessUtils } from '../utils/processUtils.js'; + * processUtils.exit = jest.fn(); + * // ... run tests ... + * resetProcessUtils(); // restore original behavior + */ + +export interface ProcessUtils { + /** + * Exit the process with the given code. + * In tests, this can be mocked to prevent actual exit. + */ + exit: (code?: number) => never; + + /** + * Standard output operations + */ + stdout: { + write: (data: string) => boolean; + isTTY: boolean; + }; + + /** + * Standard error operations + */ + stderr: { + write: (data: string) => boolean; + isTTY: boolean; + }; + + /** + * Standard input operations + */ + stdin: { + isTTY: boolean; + setRawMode?: (mode: boolean) => void; + on: (event: string, listener: (...args: unknown[]) => void) => void; + removeListener: ( + event: string, + listener: (...args: unknown[]) => void, + ) => void; + }; + + /** + * Get current working directory + */ + cwd: () => string; + + /** + * Register a handler for process events (e.g., 'SIGINT', 'exit') + */ + on: (event: string, listener: (...args: unknown[]) => void) => void; + + /** + * Remove a handler for process events + */ + off: (event: string, listener: (...args: unknown[]) => void) => void; + + /** + * Environment variables (read-only access) + */ + env: typeof process.env; +} + +// Store original references for reset +const originalExit = process.exit.bind(process); +const originalStdoutWrite = process.stdout.write.bind(process.stdout); +const originalStderrWrite = process.stderr.write.bind(process.stderr); +const originalCwd = process.cwd.bind(process); +const originalOn = process.on.bind(process); +const originalOff = process.off.bind(process); + +/** + * The main process utilities object. + * All properties are mutable for testing purposes. + */ +export const processUtils: ProcessUtils = { + exit: originalExit, + + stdout: { + write: (data: string) => originalStdoutWrite(data), + get isTTY() { + return process.stdout.isTTY ?? false; + }, + }, + + stderr: { + write: (data: string) => originalStderrWrite(data), + get isTTY() { + return process.stderr.isTTY ?? false; + }, + }, + + stdin: { + get isTTY() { + return process.stdin.isTTY ?? false; + }, + setRawMode: process.stdin.setRawMode?.bind(process.stdin), + on: process.stdin.on.bind(process.stdin), + removeListener: process.stdin.removeListener.bind(process.stdin), + }, + + cwd: originalCwd, + + on: originalOn, + + off: originalOff, + + get env() { + return process.env; + }, +}; + +/** + * Reset all process utilities to their original implementations. + * Call this in test teardown to restore normal behavior. + */ +export function resetProcessUtils(): void { + processUtils.exit = originalExit; + processUtils.stdout.write = (data: string) => originalStdoutWrite(data); + processUtils.stderr.write = (data: string) => originalStderrWrite(data); + processUtils.cwd = originalCwd; + processUtils.on = originalOn; + processUtils.off = originalOff; +} + +/** + * Create a mock process utils for testing. + * Returns an object with jest mock functions. + */ +export function createMockProcessUtils(): ProcessUtils { + const exitMock = (() => { + throw new Error("process.exit called"); + }) as (code?: number) => never; + + return { + exit: exitMock, + stdout: { + write: () => true, + isTTY: false, + }, + stderr: { + write: () => true, + isTTY: false, + }, + stdin: { + isTTY: false, + setRawMode: () => {}, + on: () => {}, + removeListener: () => {}, + }, + cwd: () => "/mock/cwd", + on: () => {}, + off: () => {}, + env: {}, + }; +} + +/** + * Install mock process utils for testing. + * Returns a cleanup function to restore originals. + */ +export function installMockProcessUtils( + mock: Partial, +): () => void { + const backup = { + exit: processUtils.exit, + stdoutWrite: processUtils.stdout.write, + stderrWrite: processUtils.stderr.write, + cwd: processUtils.cwd, + on: processUtils.on, + off: processUtils.off, + }; + + if (mock.exit) processUtils.exit = mock.exit; + if (mock.stdout?.write) processUtils.stdout.write = mock.stdout.write; + if (mock.stderr?.write) processUtils.stderr.write = mock.stderr.write; + if (mock.cwd) processUtils.cwd = mock.cwd; + if (mock.on) processUtils.on = mock.on; + if (mock.off) processUtils.off = mock.off; + + return () => { + processUtils.exit = backup.exit; + processUtils.stdout.write = backup.stdoutWrite; + processUtils.stderr.write = backup.stderrWrite; + processUtils.cwd = backup.cwd; + processUtils.on = backup.on; + processUtils.off = backup.off; + }; +} diff --git a/src/utils/screen.ts b/src/utils/screen.ts index 0d6da6bb..e89a93d5 100644 --- a/src/utils/screen.ts +++ b/src/utils/screen.ts @@ -7,13 +7,15 @@ * the original screen content is restored. */ +import { processUtils } from "./processUtils.js"; + /** * Enter the alternate screen buffer. * This provides a fullscreen experience where content won't mix with * previous terminal output. Like vim or top. */ export function enterAlternateScreenBuffer(): void { - process.stdout.write("\x1b[?1049h"); + processUtils.stdout.write("\x1b[?1049h"); } /** @@ -21,5 +23,46 @@ export function enterAlternateScreenBuffer(): void { * This returns the terminal to its original state before enterAlternateScreen() was called. */ export function exitAlternateScreenBuffer(): void { - process.stdout.write("\x1b[?1049l"); + processUtils.stdout.write("\x1b[?1049l"); +} + +/** + * Clear the terminal screen. + * Uses ANSI escape sequences to clear the screen and move cursor to top-left. + */ +export function clearScreen(): void { + // Clear entire screen and move cursor to top-left + processUtils.stdout.write("\x1b[2J\x1b[H"); +} + +/** + * Show the terminal cursor. + * Uses ANSI escape sequence to make the cursor visible. + */ +export function showCursor(): void { + processUtils.stdout.write("\x1b[?25h"); +} + +/** + * Hide the terminal cursor. + * Uses ANSI escape sequence to make the cursor invisible. + */ +export function hideCursor(): void { + processUtils.stdout.write("\x1b[?25l"); +} + +/** + * Reset terminal to a clean state. + * Exits alternate screen buffer, clears the screen, and resets cursor. + * Also resets terminal attributes to ensure clean state for subprocesses. + */ +export function resetTerminal(): void { + exitAlternateScreenBuffer(); + clearScreen(); + // Reset terminal attributes (SGR reset) + processUtils.stdout.write("\x1b[0m"); + // Move cursor to home position + processUtils.stdout.write("\x1b[H"); + // Show cursor to ensure it's visible + showCursor(); } diff --git a/src/utils/ssh.ts b/src/utils/ssh.ts index 443eb780..14b67e8a 100644 --- a/src/utils/ssh.ts +++ b/src/utils/ssh.ts @@ -4,6 +4,7 @@ import { writeFile, mkdir, chmod } from "fs/promises"; import { join } from "path"; import { homedir } from "os"; import { getClient } from "./client.js"; +import { processUtils } from "./processUtils.js"; const execAsync = promisify(exec); @@ -139,7 +140,7 @@ export async function waitForReady( * Get SSH URL based on environment */ export function getSSHUrl(): string { - const env = process.env.RUNLOOP_ENV?.toLowerCase(); + const env = processUtils.env.RUNLOOP_ENV?.toLowerCase(); return env === "dev" ? "ssh.runloop.pro:443" : "ssh.runloop.ai:443"; } @@ -182,7 +183,7 @@ export async function executeSSH( if (stderr) console.error(stderr); } catch (error) { console.error("SSH command failed:", error); - process.exit(1); + processUtils.exit(1); } } diff --git a/src/utils/terminalDetection.ts b/src/utils/terminalDetection.ts index 53e3e69b..ee55ffe8 100644 --- a/src/utils/terminalDetection.ts +++ b/src/utils/terminalDetection.ts @@ -23,36 +23,76 @@ function getLuminance(r: number, g: number, b: number): number { /** * Parse RGB color from terminal response - * Expected format: rgb:RRRR/GGGG/BBBB or similar variations + * Terminal responses can come in various formats: + * - OSC 11;rgb:RRRR/GGGG/BBBB (xterm-style, 4 hex digits per channel) + * - OSC 11;rgb:RR/GG/BB (short format, 2 hex digits per channel) + * - rgb:RRRR/GGGG/BBBB (without OSC prefix) */ function parseRGBResponse(response: string): { r: number; g: number; b: number; } | null { - // Match patterns like: rgb:RRRR/GGGG/BBBB or rgba:RRRR/GGGG/BBBB/AAAA - const rgbMatch = response.match( - /rgba?:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i, + // Try multiple patterns to handle different terminal response formats + + // Pattern 1: OSC 11;rgb:RRRR/GGGG/BBBB or 11;rgb:RRRR/GGGG/BBBB (xterm-style, 4 hex digits) + let match = response.match( + /11;rgb:([0-9a-f]{4})\/([0-9a-f]{4})\/([0-9a-f]{4})/i, ); - if (!rgbMatch) { - return null; + if (match) { + // Take first 2 hex digits and convert to 0-255 + const r = parseInt(match[1].substring(0, 2), 16); + const g = parseInt(match[2].substring(0, 2), 16); + const b = parseInt(match[3].substring(0, 2), 16); + return { r, g, b }; + } + + // Pattern 2: OSC 11;rgb:RR/GG/BB (short format, 2 hex digits) + match = response.match(/11;rgb:([0-9a-f]{2})\/([0-9a-f]{2})\/([0-9a-f]{2})/i); + if (match) { + const r = parseInt(match[1], 16); + const g = parseInt(match[2], 16); + const b = parseInt(match[3], 16); + return { r, g, b }; } - // Parse hex values and normalize to 0-255 range - const r = parseInt(rgbMatch[1].substring(0, 2), 16); - const g = parseInt(rgbMatch[2].substring(0, 2), 16); - const b = parseInt(rgbMatch[3].substring(0, 2), 16); + // Pattern 3: rgb:RRRR/GGGG/BBBB (without OSC prefix, 4 hex digits) + match = response.match(/rgb:([0-9a-f]{4})\/([0-9a-f]{4})\/([0-9a-f]{4})/i); + if (match) { + const r = parseInt(match[1].substring(0, 2), 16); + const g = parseInt(match[2].substring(0, 2), 16); + const b = parseInt(match[3].substring(0, 2), 16); + return { r, g, b }; + } + + // Pattern 4: rgb:RR/GG/BB (without OSC prefix, 2 hex digits) + match = response.match(/rgb:([0-9a-f]{2})\/([0-9a-f]{2})\/([0-9a-f]{2})/i); + if (match) { + const r = parseInt(match[1], 16); + const g = parseInt(match[2], 16); + const b = parseInt(match[3], 16); + return { r, g, b }; + } - return { r, g, b }; + // Pattern 5: Generic pattern for any hex values separated by / + match = response.match(/([0-9a-f]{2,4})\/([0-9a-f]{2,4})\/([0-9a-f]{2,4})/i); + if (match) { + const r = parseInt(match[1].substring(0, 2), 16); + const g = parseInt(match[2].substring(0, 2), 16); + const b = parseInt(match[3].substring(0, 2), 16); + return { r, g, b }; + } + + return null; } /** * Detect terminal theme by querying background color * Returns 'light' or 'dark' based on background luminance, or null if detection fails * - * NOTE: This is disabled by default to prevent flashing. Theme detection writes - * escape sequences to stdout which can cause visible flashing on the terminal. - * Users can explicitly enable it with RUNLOOP_ENABLE_THEME_DETECTION=1 + * NOTE: Theme detection runs automatically when theme preference is "auto". + * Users can disable it by setting RUNLOOP_DISABLE_THEME_DETECTION=1 to prevent + * any potential terminal flashing. */ export async function detectTerminalTheme(): Promise { // Skip detection in non-TTY environments @@ -60,59 +100,116 @@ export async function detectTerminalTheme(): Promise { return null; } - // Theme detection is now OPT-IN instead of OPT-OUT to prevent flashing - // Users need to explicitly enable it - if (process.env.RUNLOOP_ENABLE_THEME_DETECTION !== "1") { + // Allow users to opt-out of theme detection if they experience flashing + if (process.env.RUNLOOP_DISABLE_THEME_DETECTION === "1") { return null; } return new Promise((resolve) => { let response = ""; let timeout: ReturnType; + let hasResolved = false; const cleanup = () => { - stdin.setRawMode(false); - stdin.pause(); - stdin.removeListener("data", onData); - clearTimeout(timeout); + try { + stdin.setRawMode(false); + stdin.pause(); + stdin.removeListener("data", onData); + stdin.removeListener("readable", onReadable); + } catch { + // Ignore errors during cleanup + } + if (timeout) { + clearTimeout(timeout); + } }; - const onData = (chunk: Buffer) => { - response += chunk.toString(); - - // Check if we have a complete response (ends with ESC \ or BEL) - if (response.includes("\x1b\\") || response.includes("\x07")) { - cleanup(); + const finish = (result: ThemeMode | null) => { + if (hasResolved) return; + hasResolved = true; + cleanup(); + resolve(result); + }; + const onData = (chunk: Buffer) => { + if (hasResolved) return; + + const text = chunk.toString("utf8"); + response += text; + + // Check if we have a complete response + // Terminal responses typically end with ESC \ (ST) or BEL (\x07) + // Some terminals may also send the response without the OSC prefix + if ( + response.includes("\x1b\\") || + response.includes("\x07") || + response.includes("\x1b]") + ) { const rgb = parseRGBResponse(response); if (rgb) { const luminance = getLuminance(rgb.r, rgb.g, rgb.b); // Threshold: luminance > 0.5 is considered light background - resolve(luminance > 0.5 ? "light" : "dark"); - } else { - resolve(null); + finish(luminance > 0.5 ? "light" : "dark"); + return; + } + // If we got a response but couldn't parse it, check if it's complete + if (response.includes("\x1b\\") || response.includes("\x07")) { + finish(null); + return; + } + } + }; + + // Some terminals may send responses through the readable event instead + const onReadable = () => { + if (hasResolved) return; + + let chunk: Buffer | null; + while ((chunk = stdin.read()) !== null) { + const text = chunk.toString("utf8"); + response += text; + + if (text.includes("\x1b\\") || text.includes("\x07")) { + const rgb = parseRGBResponse(response); + if (rgb) { + const luminance = getLuminance(rgb.r, rgb.g, rgb.b); + finish(luminance > 0.5 ? "light" : "dark"); + return; + } + finish(null); + return; } } }; // Set timeout for terminals that don't support the query timeout = setTimeout(() => { - cleanup(); - resolve(null); - }, 50); // 50ms timeout - quick to minimize any visual flashing + finish(null); + }, 200); // Increased timeout to 200ms to give terminals more time to respond try { // Enable raw mode to capture escape sequences stdin.setRawMode(true); stdin.resume(); + + // Listen to both data and readable events stdin.on("data", onData); + stdin.on("readable", onReadable); // Query background color using OSC 11 sequence // Format: ESC ] 11 ; ? ESC \ + // Some terminals may need the BEL terminator instead stdout.write("\x1b]11;?\x1b\\"); + + // Also try with BEL terminator as some terminals prefer it + // (but wait a bit to see if first one works) + setTimeout(() => { + if (!hasResolved) { + stdout.write("\x1b]11;?\x07"); + } + }, 10); } catch { - cleanup(); - resolve(null); + finish(null); } }); } diff --git a/src/utils/theme.ts b/src/utils/theme.ts index f944e8b3..1d57bc02 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -9,6 +9,7 @@ import { getDetectedTheme, setDetectedTheme, } from "./config.js"; +import chalk from "chalk"; // Color palette structure type ColorPalette = { @@ -106,25 +107,19 @@ export async function initializeTheme(): Promise { let detectedTheme: ThemeMode | null = null; // Auto-detect if preference is 'auto' + // Always detect on startup to support different terminal profiles + // (users may have different terminal profiles with different themes) if (preference === "auto") { - // Check cache first - only detect if we haven't cached a result - const cachedTheme = getDetectedTheme(); - - if (cachedTheme) { - // Use cached detection result (no flashing!) - detectedTheme = cachedTheme; - } else { - // First time detection - run it and cache the result - try { - detectedTheme = await detectTerminalTheme(); - if (detectedTheme) { - // Cache the result so we don't detect again - setDetectedTheme(detectedTheme); - } - } catch { - // Detection failed, fall back to dark mode - detectedTheme = null; + try { + detectedTheme = await detectTerminalTheme(); + // Cache the result for reference, but we always re-detect on startup + if (detectedTheme) { + setDetectedTheme(detectedTheme); } + } catch { + // Detection failed, fall back to cached value or dark mode + const cachedTheme = getDetectedTheme(); + detectedTheme = cachedTheme || null; } } @@ -157,13 +152,35 @@ export type ColorName = keyof ColorPalette; export type ColorValue = ColorPalette[ColorName]; /** - * Get chalk function for a color name + * Get hex color value for a color name * Useful for applying colors dynamically */ export function getChalkColor(colorName: ColorName): string { return activeColors[colorName]; } +/** + * Get chalk text color function for a color name + * Converts hex color to chalk function for text coloring + * @param colorName - Name of the color from the palette + * @returns Chalk function that can be used to color text + */ +export function getChalkTextColor(colorName: ColorName) { + const hexColor = activeColors[colorName]; + return chalk.hex(hexColor); +} + +/** + * Get chalk background color function for a color name + * Converts hex color to chalk function for background coloring + * @param colorName - Name of the color from the palette + * @returns Chalk function that can be used to color backgrounds + */ +export function getChalkBgColor(colorName: ColorName) { + const hexColor = activeColors[colorName]; + return chalk.bgHex(hexColor); +} + /** * Check if we should use inverted colors (light mode) * Useful for components that need to explicitly set backgrounds diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 00000000..91c637d5 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,17 @@ +/** + * Version information for the CLI. + * Separated from cli.ts to allow importing without side effects. + */ + +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +// Get version from package.json +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageJson = JSON.parse( + readFileSync(join(__dirname, "../package.json"), "utf8"), +); + +export const VERSION: string = packageJson.version; diff --git a/tests/__mocks__/conf.js b/tests/__mocks__/conf.js deleted file mode 100644 index eea064d5..00000000 --- a/tests/__mocks__/conf.js +++ /dev/null @@ -1,39 +0,0 @@ -class Conf { - constructor(options = {}) { - this.store = new Map(); - this.defaults = options.defaults || {}; - } - - get(key, defaultValue) { - return this.store.get(key) ?? this.defaults[key] ?? defaultValue; - } - - set(key, value) { - this.store.set(key, value); - } - - delete(key) { - this.store.delete(key); - } - - clear() { - this.store.clear(); - } - - has(key) { - return this.store.has(key) || key in this.defaults; - } - - get size() { - return this.store.size; - } - - *[Symbol.iterator]() { - for (const [key, value] of this.store) { - yield [key, value]; - } - } -} - -module.exports = Conf; -module.exports.default = Conf; diff --git a/tests/__mocks__/conf.ts b/tests/__mocks__/conf.ts new file mode 100644 index 00000000..9078a7a2 --- /dev/null +++ b/tests/__mocks__/conf.ts @@ -0,0 +1,47 @@ +// Mock for conf ESM module +interface ConfOptions { + defaults?: Record; +} + +class Conf { + private store: Map; + private defaults: Record; + + constructor(options: ConfOptions = {}) { + this.store = new Map(); + this.defaults = options.defaults || {}; + } + + get(key: string, defaultValue?: T): T | undefined { + return (this.store.get(key) as T) ?? (this.defaults[key] as T) ?? defaultValue; + } + + set(key: string, value: unknown): void { + this.store.set(key, value); + } + + delete(key: string): void { + this.store.delete(key); + } + + clear(): void { + this.store.clear(); + } + + has(key: string): boolean { + return this.store.has(key) || key in this.defaults; + } + + get size(): number { + return this.store.size; + } + + *[Symbol.iterator](): IterableIterator<[string, unknown]> { + for (const [key, value] of this.store) { + yield [key, value]; + } + } +} + +export default Conf; + diff --git a/tests/__mocks__/figures.js b/tests/__mocks__/figures.js deleted file mode 100644 index 26307365..00000000 --- a/tests/__mocks__/figures.js +++ /dev/null @@ -1,66 +0,0 @@ -// Mock for figures module -module.exports = { - tick: '✓', - cross: '✗', - star: '★', - square: '▇', - squareSmall: '◻', - squareSmallFilled: '◼', - play: '▶', - circle: '◯', - circleFilled: '◉', - circleDotted: '◌', - circleDouble: '◎', - circleCircle: 'ⓞ', - circleCross: 'ⓧ', - circlePipe: 'Ⓘ', - circleQuestionMark: '?⃝', - bullet: '•', - dot: '․', - line: '─', - ellipsis: '…', - pointer: '❯', - pointerSmall: '›', - info: 'ℹ', - warning: '⚠', - hamburger: '☰', - smiley: '㋡', - mustache: '෴', - heart: '♥', - arrowUp: '↑', - arrowDown: '↓', - arrowLeft: '←', - arrowRight: '→', - radioOn: '◉', - radioOff: '◯', - checkboxOn: '☑', - checkboxOff: '☐', - checkboxOnOff: '☒', - bulletWhite: '◦', - home: '⌂', - menu: '☰', - line: '─', - ellipsis: '…', - pointer: '❯', - pointerSmall: '›', - info: 'ℹ', - warning: '⚠', - hamburger: '☰', - smiley: '㋡', - mustache: '෴', - heart: '♥', - arrowUp: '↑', - arrowDown: '↓', - arrowLeft: '←', - arrowRight: '→', - radioOn: '◉', - radioOff: '◯', - checkboxOn: '☑', - checkboxOff: '☐', - checkboxOnOff: '☒', - bulletWhite: '◦', - home: '⌂', - menu: '☰' -}; - - diff --git a/tests/__mocks__/figures.ts b/tests/__mocks__/figures.ts new file mode 100644 index 00000000..7e0f2b10 --- /dev/null +++ b/tests/__mocks__/figures.ts @@ -0,0 +1,46 @@ +// Mock for figures ESM module +const figures = { + tick: "✓", + cross: "✗", + star: "★", + square: "▇", + squareSmall: "◻", + squareSmallFilled: "◼", + play: "▶", + circle: "◯", + circleFilled: "●", + circleDotted: "◌", + circleDouble: "◎", + circleCircle: "ⓞ", + circleCross: "ⓧ", + circlePipe: "Ⓘ", + circleQuestionMark: "?⃝", + bullet: "•", + dot: "․", + line: "─", + ellipsis: "…", + pointer: "❯", + pointerSmall: "›", + info: "ℹ", + warning: "⚠", + hamburger: "☰", + smiley: "㋡", + mustache: "෴", + heart: "♥", + arrowUp: "↑", + arrowDown: "↓", + arrowLeft: "←", + arrowRight: "→", + radioOn: "◉", + radioOff: "◯", + checkboxOn: "☑", + checkboxOff: "☐", + checkboxOnOff: "☒", + bulletWhite: "◦", + home: "⌂", + menu: "☰", + questionMarkPrefix: "?", + identical: "≡", +}; + +export default figures; diff --git a/tests/__mocks__/is-unicode-supported.js b/tests/__mocks__/is-unicode-supported.js deleted file mode 100644 index 4b12e86e..00000000 --- a/tests/__mocks__/is-unicode-supported.js +++ /dev/null @@ -1,6 +0,0 @@ -// Mock for is-unicode-supported module -module.exports = function isUnicodeSupported() { - return true; -}; - - diff --git a/tests/__mocks__/is-unicode-supported.ts b/tests/__mocks__/is-unicode-supported.ts new file mode 100644 index 00000000..ee714c6a --- /dev/null +++ b/tests/__mocks__/is-unicode-supported.ts @@ -0,0 +1,5 @@ +// Mock for is-unicode-supported ESM module +export default function isUnicodeSupported(): boolean { + return true; +} + diff --git a/tests/__mocks__/signal-exit.ts b/tests/__mocks__/signal-exit.ts new file mode 100644 index 00000000..59210cdf --- /dev/null +++ b/tests/__mocks__/signal-exit.ts @@ -0,0 +1,9 @@ +/** + * Mock for signal-exit module to avoid ESM teardown issues in Jest + */ + +const noop = () => () => {}; + +export default noop; +export const onExit = noop; + diff --git a/tests/__tests__/components/ActionsPopup.test.tsx b/tests/__tests__/components/ActionsPopup.test.tsx new file mode 100644 index 00000000..ff7760c6 --- /dev/null +++ b/tests/__tests__/components/ActionsPopup.test.tsx @@ -0,0 +1,98 @@ +/** + * Tests for ActionsPopup component + */ +import React from "react"; +import { render } from "ink-testing-library"; +import { ActionsPopup } from "../../../src/components/ActionsPopup.js"; + +describe("ActionsPopup", () => { + const mockDevbox = { id: "dbx_123", name: "test-devbox" }; + const mockOperations = [ + { + key: "logs", + label: "View Logs", + color: "blue", + icon: "ℹ", + shortcut: "l", + }, + { + key: "exec", + label: "Execute", + color: "green", + icon: "▶", + shortcut: "e", + }, + ]; + + it("renders without crashing", () => { + const { lastFrame } = render( + {}} + />, + ); + + expect(lastFrame()).toBeTruthy(); + }); + + it("displays the Quick Actions title", () => { + const { lastFrame } = render( + {}} + />, + ); + + expect(lastFrame()).toContain("Quick Actions"); + }); + + it("renders all operations", () => { + const { lastFrame } = render( + {}} + />, + ); + + const frame = lastFrame() || ""; + expect(frame).toContain("View Logs"); + expect(frame).toContain("Execute"); + }); + + it("shows keyboard shortcuts", () => { + const { lastFrame } = render( + {}} + />, + ); + + const frame = lastFrame() || ""; + expect(frame).toContain("[l]"); + expect(frame).toContain("[e]"); + }); + + it("displays navigation hints", () => { + const { lastFrame } = render( + {}} + />, + ); + + const frame = lastFrame() || ""; + expect(frame).toContain("Nav"); + expect(frame).toContain("Enter"); + expect(frame).toContain("Esc"); + }); +}); diff --git a/tests/__tests__/components/Banner.test.tsx b/tests/__tests__/components/Banner.test.tsx new file mode 100644 index 00000000..b90a4c9f --- /dev/null +++ b/tests/__tests__/components/Banner.test.tsx @@ -0,0 +1,20 @@ +/** + * Tests for Banner component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { Banner } from '../../../src/components/Banner.js'; + +describe('Banner', () => { + it('renders without crashing', () => { + const { lastFrame } = render(); + expect(lastFrame()).toBeTruthy(); + }); + + it('is memoized', () => { + // Banner is wrapped in React.memo + expect(Banner).toBeDefined(); + expect(typeof Banner).toBe('object'); // React.memo returns an object + }); +}); + diff --git a/tests/__tests__/components/Breadcrumb.test.tsx b/tests/__tests__/components/Breadcrumb.test.tsx new file mode 100644 index 00000000..2cf0a634 --- /dev/null +++ b/tests/__tests__/components/Breadcrumb.test.tsx @@ -0,0 +1,71 @@ +/** + * Tests for Breadcrumb component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { Breadcrumb } from '../../../src/components/Breadcrumb.js'; + +describe('Breadcrumb', () => { + it('renders without crashing', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays the rl prefix', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('rl'); + }); + + it('renders single breadcrumb item', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('Devboxes'); + }); + + it('renders multiple breadcrumb items with separators', () => { + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Home'); + expect(frame).toContain('Devboxes'); + expect(frame).toContain('Detail'); + expect(frame).toContain('›'); // separator + }); + + it('truncates long labels', () => { + const longLabel = 'A'.repeat(100); + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('...'); + // Frame should not contain the full long label + expect(frame).not.toContain(longLabel); + }); + + it('shows dev environment indicator when RUNLOOP_ENV is dev', () => { + const originalEnv = process.env.RUNLOOP_ENV; + process.env.RUNLOOP_ENV = 'dev'; + + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('(dev)'); + + process.env.RUNLOOP_ENV = originalEnv; + }); +}); + diff --git a/tests/__tests__/components/DetailView.test.tsx b/tests/__tests__/components/DetailView.test.tsx new file mode 100644 index 00000000..af98885d --- /dev/null +++ b/tests/__tests__/components/DetailView.test.tsx @@ -0,0 +1,145 @@ +/** + * Tests for DetailView component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { DetailView, buildDetailSections } from '../../../src/components/DetailView.js'; + +describe('DetailView', () => { + it('renders without crashing', () => { + const sections = [ + { + title: 'Info', + items: [ + { label: 'ID', value: '123' }, + { label: 'Name', value: 'Test' }, + ], + }, + ]; + + const { lastFrame } = render(); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays section titles', () => { + const sections = [ + { + title: 'Basic Information', + items: [{ label: 'ID', value: '123' }], + }, + ]; + + const { lastFrame } = render(); + expect(lastFrame()).toContain('Basic Information'); + }); + + it('displays labels and values', () => { + const sections = [ + { + title: 'Details', + items: [ + { label: 'Status', value: 'running' }, + { label: 'Created', value: '2024-01-01' }, + ], + }, + ]; + + const { lastFrame } = render(); + + const frame = lastFrame() || ''; + expect(frame).toContain('Status'); + expect(frame).toContain('running'); + expect(frame).toContain('Created'); + expect(frame).toContain('2024-01-01'); + }); + + it('renders multiple sections', () => { + const sections = [ + { title: 'Section 1', items: [{ label: 'A', value: '1' }] }, + { title: 'Section 2', items: [{ label: 'B', value: '2' }] }, + ]; + + const { lastFrame } = render(); + + const frame = lastFrame() || ''; + expect(frame).toContain('Section 1'); + expect(frame).toContain('Section 2'); + }); +}); + +describe('buildDetailSections', () => { + it('builds sections from data and config', () => { + const data = { + id: 'test-123', + name: 'Test Name', + status: 'active', + }; + + const config = { + 'Basic Info': { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + ], + }, + 'Status': { + fields: [ + { key: 'status', label: 'Current Status' }, + ], + }, + }; + + const sections = buildDetailSections(data, config); + + expect(sections).toHaveLength(2); + expect(sections[0].title).toBe('Basic Info'); + expect(sections[0].items).toHaveLength(2); + expect(sections[1].title).toBe('Status'); + }); + + it('filters out undefined/null values', () => { + const data = { + id: 'test-123', + name: undefined, + status: null, + }; + + const config = { + 'Info': { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { key: 'status', label: 'Status' }, + ], + }, + }; + + const sections = buildDetailSections(data, config); + + expect(sections[0].items).toHaveLength(1); + expect(sections[0].items[0].label).toBe('ID'); + }); + + it('applies custom formatters', () => { + const data = { + timestamp: 1704067200000, // 2024-01-01 + }; + + const config = { + 'Dates': { + fields: [ + { + key: 'timestamp', + label: 'Date', + formatter: (val: unknown) => new Date(val as number).toISOString().split('T')[0], + }, + ], + }, + }; + + const sections = buildDetailSections(data, config); + + expect(sections[0].items[0].value).toBe('2024-01-01'); + }); +}); + diff --git a/tests/__tests__/components/DevboxActionsMenu.test.tsx b/tests/__tests__/components/DevboxActionsMenu.test.tsx new file mode 100644 index 00000000..4ee683e1 --- /dev/null +++ b/tests/__tests__/components/DevboxActionsMenu.test.tsx @@ -0,0 +1,85 @@ +/** + * Tests for DevboxActionsMenu component + * + * Note: This component uses useNavigation hook which requires + * the navigation store mock from setup-components.ts + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { DevboxActionsMenu } from '../../../src/components/DevboxActionsMenu.js'; + +describe('DevboxActionsMenu', () => { + const mockDevbox = { + id: 'dbx_123', + name: 'test-devbox', + status: 'running', + }; + + it('renders without crashing', () => { + const { lastFrame } = render( + {}} + /> + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('renders with devbox name', () => { + const { lastFrame } = render( + {}} + /> + ); + // Component should render something + expect(lastFrame()).toBeDefined(); + }); + + it('accepts onBack callback', () => { + const onBack = () => {}; + const { lastFrame } = render( + + ); + expect(lastFrame()).toBeDefined(); + }); + + it('accepts breadcrumbItems prop', () => { + const { lastFrame } = render( + {}} + breadcrumbItems={[ + { label: 'Home' }, + { label: 'Devboxes', active: true }, + ]} + /> + ); + expect(lastFrame()).toBeDefined(); + }); + + it('handles suspended devbox status', () => { + const suspendedDevbox = { ...mockDevbox, status: 'suspended' }; + const { lastFrame } = render( + {}} + /> + ); + expect(lastFrame()).toBeDefined(); + }); + + it('handles initialOperation prop', () => { + const { lastFrame } = render( + {}} + initialOperation="logs" + /> + ); + expect(lastFrame()).toBeDefined(); + }); +}); diff --git a/tests/__tests__/components/DevboxCard.test.tsx b/tests/__tests__/components/DevboxCard.test.tsx new file mode 100644 index 00000000..5ad88d75 --- /dev/null +++ b/tests/__tests__/components/DevboxCard.test.tsx @@ -0,0 +1,101 @@ +/** + * Tests for DevboxCard component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { DevboxCard } from '../../../src/components/DevboxCard.js'; + +describe('DevboxCard', () => { + it('renders without crashing', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays devbox id', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('dbx_test_id'); + }); + + it('displays devbox name when provided', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('my-devbox'); + }); + + it('shows running status icon', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('✓'); // tick icon for running + }); + + it('shows provisioning status icon', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('…'); // ellipsis for provisioning + }); + + it('shows failed status icon', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('✗'); // cross for failed + }); + + it('displays created date when provided', () => { + const createdAt = '2024-01-15T10:00:00.000Z'; + const { lastFrame } = render( + + ); + + // Should contain the date portion + const frame = lastFrame() || ''; + expect(frame).toContain('1/15/2024'); + }); + + it('truncates long names', () => { + const longName = 'very-long-devbox-name-that-exceeds-limit'; + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + // Should be truncated to 18 chars + expect(frame).toContain('very-long-devbox-n'); + expect(frame).not.toContain(longName); + }); +}); + diff --git a/tests/__tests__/components/DevboxCreatePage.test.tsx b/tests/__tests__/components/DevboxCreatePage.test.tsx new file mode 100644 index 00000000..1bdb246d --- /dev/null +++ b/tests/__tests__/components/DevboxCreatePage.test.tsx @@ -0,0 +1,97 @@ +/** + * Tests for DevboxCreatePage component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { DevboxCreatePage } from '../../../src/components/DevboxCreatePage.js'; + +describe('DevboxCreatePage', () => { + it('renders without crashing', () => { + const { lastFrame } = render( + {}} /> + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays breadcrumb with Create label', () => { + const { lastFrame } = render( + {}} /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Devboxes'); + expect(frame).toContain('Create'); + }); + + it('shows Devbox Create action', () => { + const { lastFrame } = render( + {}} /> + ); + expect(lastFrame()).toContain('Devbox Create'); + }); + + it('shows form fields', () => { + const { lastFrame } = render( + {}} /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Name'); + expect(frame).toContain('Architecture'); + expect(frame).toContain('Resource Size'); + }); + + it('displays default architecture value', () => { + const { lastFrame } = render( + {}} /> + ); + expect(lastFrame()).toContain('arm64'); + }); + + it('displays default resource size value', () => { + const { lastFrame } = render( + {}} /> + ); + expect(lastFrame()).toContain('SMALL'); + }); + + it('shows Keep Alive field', () => { + const { lastFrame } = render( + {}} /> + ); + expect(lastFrame()).toContain('Keep Alive'); + }); + + it('shows optional fields', () => { + const { lastFrame } = render( + {}} /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Blueprint ID'); + expect(frame).toContain('Snapshot ID'); + expect(frame).toContain('Metadata'); + }); + + it('shows navigation help', () => { + const { lastFrame } = render( + {}} /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Navigate'); + expect(frame).toContain('Create'); + expect(frame).toContain('Cancel'); + }); + + it('accepts initial blueprint ID', () => { + const { lastFrame } = render( + {}} + initialBlueprintId="bp_initial_123" + /> + ); + expect(lastFrame()).toContain('bp_initial_123'); + }); +}); + diff --git a/tests/__tests__/components/DevboxDetailPage.test.tsx b/tests/__tests__/components/DevboxDetailPage.test.tsx new file mode 100644 index 00000000..8bfb4ad0 --- /dev/null +++ b/tests/__tests__/components/DevboxDetailPage.test.tsx @@ -0,0 +1,126 @@ +/** + * Tests for DevboxDetailPage component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { DevboxDetailPage } from '../../../src/components/DevboxDetailPage.js'; + +describe('DevboxDetailPage', () => { + const mockDevbox = { + id: 'dbx_test_123', + name: 'test-devbox', + status: 'running', + create_time_ms: Date.now() - 3600000, // 1 hour ago + capabilities: ['shell', 'code'], + launch_parameters: { + architecture: 'arm64', + resource_size_request: 'SMALL', + }, + }; + + it('renders without crashing', () => { + const { lastFrame } = render( + {}} + /> + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays devbox name', () => { + const { lastFrame } = render( + {}} + /> + ); + expect(lastFrame()).toContain('test-devbox'); + }); + + it('displays devbox id', () => { + const { lastFrame } = render( + {}} + /> + ); + expect(lastFrame()).toContain('dbx_test_123'); + }); + + it('shows status badge', () => { + const { lastFrame } = render( + {}} + /> + ); + expect(lastFrame()).toContain('RUNNING'); + }); + + it('shows Actions section', () => { + const { lastFrame } = render( + {}} + /> + ); + expect(lastFrame()).toContain('Actions'); + }); + + it('shows available operations', () => { + const { lastFrame } = render( + {}} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('View Logs'); + expect(frame).toContain('Execute Command'); + }); + + it('shows navigation help', () => { + const { lastFrame } = render( + {}} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Navigate'); + expect(frame).toContain('Execute'); + expect(frame).toContain('Back'); + }); + + it('displays resource information', () => { + const { lastFrame } = render( + {}} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Resources'); + expect(frame).toContain('SMALL'); + expect(frame).toContain('arm64'); + }); + + it('displays capabilities', () => { + const { lastFrame } = render( + {}} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Capabilities'); + expect(frame).toContain('shell'); + expect(frame).toContain('code'); + }); +}); + diff --git a/tests/__tests__/components/ErrorBoundary.test.tsx b/tests/__tests__/components/ErrorBoundary.test.tsx new file mode 100644 index 00000000..4a1ac9f8 --- /dev/null +++ b/tests/__tests__/components/ErrorBoundary.test.tsx @@ -0,0 +1,86 @@ +/** + * Tests for ErrorBoundary component + */ +import React from 'react'; +import { jest } from '@jest/globals'; +import { render } from 'ink-testing-library'; +import { ErrorBoundary } from '../../../src/components/ErrorBoundary.js'; +import { Text } from 'ink'; + +// Component that throws an error +const ThrowingComponent = ({ shouldThrow }: { shouldThrow: boolean }) => { + if (shouldThrow) { + throw new Error('Test error message'); + } + return Normal render; +}; + +describe('ErrorBoundary', () => { + // Suppress console.error for expected errors + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + }); + + it('renders children when no error', () => { + const { lastFrame } = render( + + Test content + + ); + expect(lastFrame()).toContain('Test content'); + }); + + it('catches errors and displays error UI', () => { + const { lastFrame } = render( + + + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Rendering Error'); + expect(frame).toContain('Test error message'); + }); + + it('shows Ctrl+C exit instruction on error', () => { + const { lastFrame } = render( + + + + ); + + expect(lastFrame()).toContain('Ctrl+C'); + }); + + it('renders custom fallback when provided', () => { + const CustomFallback = Custom error fallback; + + const { lastFrame } = render( + + + + ); + + expect(lastFrame()).toContain('Custom error fallback'); + }); + + it('is a class component', () => { + expect(ErrorBoundary.prototype).toHaveProperty('render'); + expect(ErrorBoundary.prototype).toHaveProperty('componentDidCatch'); + }); + + it('has getDerivedStateFromError static method', () => { + expect(ErrorBoundary.getDerivedStateFromError).toBeDefined(); + + const error = new Error('Test'); + const state = ErrorBoundary.getDerivedStateFromError(error); + + expect(state.hasError).toBe(true); + expect(state.error).toBe(error); + }); +}); + diff --git a/tests/__tests__/components/ErrorMessage.test.tsx b/tests/__tests__/components/ErrorMessage.test.tsx new file mode 100644 index 00000000..0c3a73f7 --- /dev/null +++ b/tests/__tests__/components/ErrorMessage.test.tsx @@ -0,0 +1,70 @@ +/** + * Tests for ErrorMessage component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { ErrorMessage } from '../../../src/components/ErrorMessage.js'; + +describe('ErrorMessage', () => { + it('renders without crashing', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays the error message', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('Something went wrong'); + }); + + it('shows cross icon', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('✗'); + }); + + it('displays error details when provided', () => { + const error = new Error('Detailed error info'); + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Operation failed'); + expect(frame).toContain('Detailed error info'); + }); + + it('truncates long messages', () => { + const longMessage = 'A'.repeat(600); + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('...'); + // Frame should not contain the full untruncated message + expect(frame).not.toContain(longMessage); + }); + + it('truncates long error details', () => { + const longError = new Error('B'.repeat(600)); + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('...'); + }); + + it('handles error without message', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('Failed'); + }); +}); + diff --git a/tests/__tests__/components/Header.test.tsx b/tests/__tests__/components/Header.test.tsx new file mode 100644 index 00000000..0cd35cce --- /dev/null +++ b/tests/__tests__/components/Header.test.tsx @@ -0,0 +1,85 @@ +/** + * Tests for Header component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { Header } from '../../../src/components/Header.js'; + +describe('Header', () => { + it('renders without crashing', () => { + const { lastFrame } = render( +
+ ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays the title', () => { + const { lastFrame } = render( +
+ ); + expect(lastFrame()).toContain('My Header'); + }); + + it('shows the vertical bar prefix', () => { + const { lastFrame } = render( +
+ ); + expect(lastFrame()).toContain('▌'); + }); + + it('shows underline decoration', () => { + const { lastFrame } = render( +
+ ); + expect(lastFrame()).toContain('─'); + }); + + it('displays subtitle when provided', () => { + const { lastFrame } = render( +
+ ); + expect(lastFrame()).toContain('Subtitle text'); + }); + + it('truncates long titles', () => { + const longTitle = 'A'.repeat(150); + const { lastFrame } = render( +
+ ); + + const frame = lastFrame() || ''; + expect(frame).toContain('...'); + // Title should be truncated - frame should not contain the full title + expect(frame).not.toContain(longTitle); + }); + + it('truncates long subtitles', () => { + const longSubtitle = 'B'.repeat(200); + const { lastFrame } = render( +
+ ); + + const frame = lastFrame() || ''; + expect(frame).toContain('...'); + }); + + it('handles empty subtitle gracefully', () => { + const { lastFrame } = render( +
+ ); + expect(lastFrame()).toContain('Title'); + }); + + it('renders correct underline length', () => { + const { lastFrame } = render( +
+ ); + + const frame = lastFrame() || ''; + // Underline should be proportional to title length + const underlineCount = (frame.match(/─/g) || []).length; + expect(underlineCount).toBeGreaterThan(0); + expect(underlineCount).toBeLessThanOrEqual(101); // MAX_TITLE_LENGTH + 1 + }); +}); + diff --git a/tests/__tests__/components/InteractiveSpawn.test.tsx b/tests/__tests__/components/InteractiveSpawn.test.tsx new file mode 100644 index 00000000..6664d9f0 --- /dev/null +++ b/tests/__tests__/components/InteractiveSpawn.test.tsx @@ -0,0 +1,92 @@ +/** + * Tests for InteractiveSpawn component + */ +import React from 'react'; +import { jest } from '@jest/globals'; +import { render } from 'ink-testing-library'; +import { InteractiveSpawn } from '../../../src/components/InteractiveSpawn.js'; + +// Mock child_process - use unstable_mockModule for ESM +jest.unstable_mockModule('child_process', () => ({ + spawn: jest.fn(() => ({ + on: jest.fn(), + kill: jest.fn(), + killed: false, + stdout: { on: jest.fn() }, + stderr: { on: jest.fn() }, + })), +})); + +describe('InteractiveSpawn', () => { + it('renders without crashing', () => { + const { lastFrame } = render( + {}} + /> + ); + // Component renders null since it manages subprocess + expect(lastFrame()).toBe(''); + }); + + it('accepts command and args props', () => { + const onExit = jest.fn(); + const onError = jest.fn(); + + render( + + ); + + // Component should initialize without errors + expect(onError).not.toHaveBeenCalled(); + }); + + it('renders nothing to the terminal', () => { + const { lastFrame } = render( + + ); + + // InteractiveSpawn returns null - output goes directly to terminal + expect(lastFrame()).toBe(''); + }); + + it('handles onExit callback prop', () => { + const onExit = jest.fn(); + + render( + + ); + + // onExit would be called when process exits + expect(onExit).toBeDefined(); + }); + + it('handles onError callback prop', () => { + const onError = jest.fn(); + + render( + + ); + + // onError would be called on spawn error + expect(onError).toBeDefined(); + }); +}); + diff --git a/tests/__tests__/components/LogsViewer.test.tsx b/tests/__tests__/components/LogsViewer.test.tsx new file mode 100644 index 00000000..f4c7f447 --- /dev/null +++ b/tests/__tests__/components/LogsViewer.test.tsx @@ -0,0 +1,138 @@ +/** + * Tests for LogsViewer component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { LogsViewer } from '../../../src/components/LogsViewer.js'; + +describe('LogsViewer', () => { + const mockLogs = [ + { + timestamp_ms: Date.now(), + level: 'INFO', + source: 'system', + message: 'Test log message 1', + }, + { + timestamp_ms: Date.now() - 1000, + level: 'ERROR', + source: 'app', + message: 'Test error message', + }, + ]; + + it('renders without crashing', () => { + const { lastFrame } = render( + {}} + /> + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays logs', () => { + const { lastFrame } = render( + {}} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Test log message 1'); + }); + + it('shows total logs count', () => { + const { lastFrame } = render( + {}} + /> + ); + + expect(lastFrame()).toContain('2'); + expect(lastFrame()).toContain('total logs'); + }); + + it('displays breadcrumb', () => { + const { lastFrame } = render( + {}} + breadcrumbItems={[ + { label: 'Devbox' }, + { label: 'Logs', active: true }, + ]} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Devbox'); + expect(frame).toContain('Logs'); + }); + + it('shows navigation help', () => { + const { lastFrame } = render( + {}} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Navigate'); + expect(frame).toContain('Top'); + expect(frame).toContain('Bottom'); + expect(frame).toContain('Wrap'); + expect(frame).toContain('Copy'); + expect(frame).toContain('Back'); + }); + + it('shows wrap mode status', () => { + const { lastFrame } = render( + {}} + /> + ); + + expect(lastFrame()).toContain('Wrap: OFF'); + }); + + it('handles empty logs', () => { + const { lastFrame } = render( + {}} + /> + ); + + expect(lastFrame()).toContain('No logs available'); + }); + + it('accepts custom title', () => { + const { lastFrame } = render( + {}} + title="Custom Logs" + /> + ); + + // Title is used for breadcrumb default + expect(lastFrame()).toBeTruthy(); + }); + + it('shows viewing range', () => { + const { lastFrame } = render( + {}} + /> + ); + + expect(lastFrame()).toContain('Viewing'); + }); +}); + diff --git a/tests/__tests__/components/MainMenu.test.tsx b/tests/__tests__/components/MainMenu.test.tsx new file mode 100644 index 00000000..d6a0d0e8 --- /dev/null +++ b/tests/__tests__/components/MainMenu.test.tsx @@ -0,0 +1,91 @@ +/** + * Tests for MainMenu component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { MainMenu } from '../../../src/components/MainMenu.js'; + +describe('MainMenu', () => { + it('renders without crashing', () => { + const { lastFrame } = render( + {}} /> + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays menu items', () => { + const { lastFrame } = render( + {}} /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Devboxes'); + expect(frame).toContain('Blueprints'); + expect(frame).toContain('Snapshots'); + }); + + it('shows menu item descriptions', () => { + const { lastFrame } = render( + {}} /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('development environments'); + expect(frame).toContain('templates'); + expect(frame).toContain('devbox states'); + }); + + it('shows keyboard shortcuts', () => { + const { lastFrame } = render( + {}} /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('[1]'); + expect(frame).toContain('[2]'); + expect(frame).toContain('[3]'); + }); + + it('shows navigation help', () => { + const { lastFrame } = render( + {}} /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Navigate'); + expect(frame).toContain('Quick select'); + expect(frame).toContain('Select'); + expect(frame).toContain('Quit'); + }); + + it('displays version number', () => { + const { lastFrame } = render( + {}} /> + ); + + expect(lastFrame()).toContain('v'); + }); + + it('shows RUNLOOP branding', () => { + const { lastFrame } = render( + {}} /> + ); + + // Either compact or full layout should show branding + const frame = lastFrame() || ''; + expect( + frame.includes('RUNLOOP') || frame.includes('rl') + ).toBe(true); + }); + + it('shows resource selection prompt', () => { + const { lastFrame } = render( + {}} /> + ); + + const frame = lastFrame() || ''; + // Should show "Select a resource:" or have a selection pointer + expect(frame.includes('Select') || frame.includes('❯')).toBe(true); + }); +}); + diff --git a/tests/__tests__/components/MetadataDisplay.test.tsx b/tests/__tests__/components/MetadataDisplay.test.tsx new file mode 100644 index 00000000..ffcef961 --- /dev/null +++ b/tests/__tests__/components/MetadataDisplay.test.tsx @@ -0,0 +1,118 @@ +/** + * Tests for MetadataDisplay component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { MetadataDisplay } from '../../../src/components/MetadataDisplay.js'; + +describe('MetadataDisplay', () => { + it('renders without crashing', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays metadata key-value pairs', () => { + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('env'); + expect(frame).toContain('production'); + expect(frame).toContain('team'); + expect(frame).toContain('backend'); + }); + + it('shows default title', () => { + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('Metadata'); + }); + + it('shows custom title', () => { + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('Custom Title'); + }); + + it('returns null for empty metadata', () => { + const { lastFrame } = render( + + ); + + expect(lastFrame()).toBe(''); + }); + + it('renders with border when showBorder is true', () => { + const { lastFrame } = render( + + ); + + // Should contain border characters + const frame = lastFrame() || ''; + expect(frame).toBeTruthy(); + }); + + it('renders without border by default', () => { + const { lastFrame } = render( + + ); + + expect(lastFrame()).toBeTruthy(); + }); + + it('highlights selected key', () => { + const { lastFrame } = render( + + ); + + // Should render with selection indicator + expect(lastFrame()).toContain('key1'); + }); + + it('handles multiple metadata entries', () => { + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('environment'); + expect(frame).toContain('region'); + expect(frame).toContain('team'); + expect(frame).toContain('version'); + }); + + it('shows identical icon with title', () => { + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('≡'); // figures.identical + }); +}); + diff --git a/tests/__tests__/components/OperationsMenu.test.tsx b/tests/__tests__/components/OperationsMenu.test.tsx new file mode 100644 index 00000000..358b7498 --- /dev/null +++ b/tests/__tests__/components/OperationsMenu.test.tsx @@ -0,0 +1,157 @@ +/** + * Tests for OperationsMenu component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { OperationsMenu, filterOperations, Operation } from '../../../src/components/OperationsMenu.js'; + +describe('OperationsMenu', () => { + const mockOperations: Operation[] = [ + { key: 'view', label: 'View Details', color: 'blue', icon: 'ℹ' }, + { key: 'edit', label: 'Edit', color: 'green', icon: '✎' }, + { key: 'delete', label: 'Delete', color: 'red', icon: '✗' }, + ]; + + it('renders without crashing', () => { + const { lastFrame } = render( + {}} + onSelect={() => {}} + onBack={() => {}} + /> + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays Operations title', () => { + const { lastFrame } = render( + {}} + onSelect={() => {}} + onBack={() => {}} + /> + ); + expect(lastFrame()).toContain('Operations'); + }); + + it('renders all operations', () => { + const { lastFrame } = render( + {}} + onSelect={() => {}} + onBack={() => {}} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('View Details'); + expect(frame).toContain('Edit'); + expect(frame).toContain('Delete'); + }); + + it('shows icons for operations', () => { + const { lastFrame } = render( + {}} + onSelect={() => {}} + onBack={() => {}} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('ℹ'); + expect(frame).toContain('✎'); + expect(frame).toContain('✗'); + }); + + it('shows pointer for selected item', () => { + const { lastFrame } = render( + {}} + onSelect={() => {}} + onBack={() => {}} + /> + ); + + expect(lastFrame()).toContain('❯'); // figures.pointer + }); + + it('shows navigation help', () => { + const { lastFrame } = render( + {}} + onSelect={() => {}} + onBack={() => {}} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Navigate'); + expect(frame).toContain('Select'); + expect(frame).toContain('Back'); + }); + + it('shows additional actions when provided', () => { + const { lastFrame } = render( + {}} + onSelect={() => {}} + onBack={() => {}} + additionalActions={[ + { key: 'r', label: 'Refresh', handler: () => {} }, + ]} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('[r]'); + expect(frame).toContain('Refresh'); + }); +}); + +describe('filterOperations', () => { + const operations: Operation[] = [ + { key: 'view', label: 'View', color: 'blue', icon: 'i', needsInput: false }, + { key: 'edit', label: 'Edit', color: 'green', icon: 'e', needsInput: true }, + { key: 'delete', label: 'Delete', color: 'red', icon: 'x', needsInput: false }, + ]; + + it('filters operations based on condition', () => { + const filtered = filterOperations(operations, (op) => op.needsInput === false); + + expect(filtered).toHaveLength(2); + expect(filtered.map(o => o.key)).toEqual(['view', 'delete']); + }); + + it('returns all operations when condition matches all', () => { + const filtered = filterOperations(operations, () => true); + expect(filtered).toHaveLength(3); + }); + + it('returns empty array when no operations match', () => { + const filtered = filterOperations(operations, () => false); + expect(filtered).toHaveLength(0); + }); + + it('filters by key', () => { + const filtered = filterOperations(operations, (op) => op.key !== 'delete'); + + expect(filtered).toHaveLength(2); + expect(filtered.map(o => o.key)).toEqual(['view', 'edit']); + }); +}); + diff --git a/tests/__tests__/components/ResourceActionsMenu.test.tsx b/tests/__tests__/components/ResourceActionsMenu.test.tsx new file mode 100644 index 00000000..4215b514 --- /dev/null +++ b/tests/__tests__/components/ResourceActionsMenu.test.tsx @@ -0,0 +1,110 @@ +/** + * Tests for ResourceActionsMenu component + * + * Note: This component uses useNavigation hook which requires + * the navigation store mock from setup-components.ts + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { ResourceActionsMenu } from '../../../src/components/ResourceActionsMenu.js'; + +describe('ResourceActionsMenu', () => { + describe('devbox mode', () => { + const mockDevbox = { + id: 'dbx_123', + name: 'test-devbox', + status: 'running', + }; + + it('renders without crashing', () => { + const { lastFrame } = render( + {}} + /> + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('accepts resource prop', () => { + const { lastFrame } = render( + {}} + /> + ); + expect(lastFrame()).toBeDefined(); + }); + }); + + describe('blueprint mode', () => { + const mockBlueprint = { + id: 'bp_123', + name: 'test-blueprint', + }; + + const mockOperations = [ + { key: 'logs', label: 'View Logs', color: 'blue', icon: 'ℹ', shortcut: 'l' }, + { key: 'create', label: 'Create Devbox', color: 'green', icon: '▶', shortcut: 'c' }, + ]; + + it('renders without crashing', () => { + const { lastFrame } = render( + {}} + onExecute={async () => {}} + /> + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('accepts operations prop', () => { + const { lastFrame } = render( + {}} + onExecute={async () => {}} + /> + ); + expect(lastFrame()).toBeDefined(); + }); + + it('accepts breadcrumbItems prop', () => { + const { lastFrame } = render( + {}} + onExecute={async () => {}} + /> + ); + expect(lastFrame()).toBeDefined(); + }); + + it('handles onExecute callback', () => { + const onExecute = async () => {}; + const { lastFrame } = render( + {}} + onExecute={onExecute} + /> + ); + expect(lastFrame()).toBeDefined(); + }); + }); +}); diff --git a/tests/__tests__/components/ResourceListView.test.tsx b/tests/__tests__/components/ResourceListView.test.tsx new file mode 100644 index 00000000..d202acfa --- /dev/null +++ b/tests/__tests__/components/ResourceListView.test.tsx @@ -0,0 +1,132 @@ +/** + * Tests for ResourceListView component + */ +import React from "react"; +import { render } from "ink-testing-library"; +import { + ResourceListView, + formatTimeAgo, + ResourceListConfig, +} from "../../../src/components/ResourceListView.js"; +import { createTextColumn } from "../../../src/components/Table.js"; + +interface TestResource { + id: string; + name: string; + status: string; +} + +describe("ResourceListView", () => { + const createConfig = ( + overrides: Partial> = {}, + ): ResourceListConfig => ({ + resourceName: "Resource", + resourceNamePlural: "Resources", + fetchResources: async () => [ + { id: "r1", name: "Resource 1", status: "active" }, + { id: "r2", name: "Resource 2", status: "pending" }, + ], + columns: [ + createTextColumn("name", "Name", (r) => r.name, { width: 20 }), + createTextColumn("status", "Status", (r) => r.status, { width: 10 }), + ], + keyExtractor: (r) => r.id, + ...overrides, + }); + + it("renders without crashing", async () => { + const config = createConfig(); + const { lastFrame } = render(); + + // Should show loading initially + expect(lastFrame()).toBeTruthy(); + }); + + it("shows loading state initially", () => { + const config = createConfig({ + fetchResources: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return []; + }, + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain("Loading"); + }); + + it("shows empty state when no resources", async () => { + const config = createConfig({ + fetchResources: async () => [], + emptyState: { + message: "No resources found", + command: "rli create", + }, + }); + + const { lastFrame } = render(); + + // Wait for async fetch + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Check for empty state or loading (depending on timing) + expect(lastFrame()).toBeTruthy(); + }); + + it("uses custom breadcrumb items", () => { + const config = createConfig({ + breadcrumbItems: [{ label: "Home" }, { label: "Custom", active: true }], + }); + + const { lastFrame } = render(); + + const frame = lastFrame() || ""; + expect(frame).toContain("Custom"); + }); + + it("shows navigation help when loaded", async () => { + const config = createConfig(); + const { lastFrame } = render(); + + // Wait for data to load - increase timeout for async fetch + await new Promise((resolve) => setTimeout(resolve, 200)); + + const frame = lastFrame() || ""; + // Should contain navigation hints OR still be loading (timing dependent) + // The important thing is it renders something + expect(frame.length).toBeGreaterThan(0); + // If loaded, should show either navigation help or resources + expect(frame).toMatch(/Navigate|Loading|Resource/); + }); +}); + +describe("formatTimeAgo", () => { + it("formats seconds ago", () => { + const timestamp = Date.now() - 30 * 1000; + expect(formatTimeAgo(timestamp)).toBe("30s ago"); + }); + + it("formats minutes ago", () => { + const timestamp = Date.now() - 5 * 60 * 1000; + expect(formatTimeAgo(timestamp)).toBe("5m ago"); + }); + + it("formats hours ago", () => { + const timestamp = Date.now() - 3 * 60 * 60 * 1000; + expect(formatTimeAgo(timestamp)).toBe("3h ago"); + }); + + it("formats days ago", () => { + const timestamp = Date.now() - 7 * 24 * 60 * 60 * 1000; + expect(formatTimeAgo(timestamp)).toBe("7d ago"); + }); + + it("formats months ago", () => { + const timestamp = Date.now() - 60 * 24 * 60 * 60 * 1000; + expect(formatTimeAgo(timestamp)).toBe("2mo ago"); + }); + + it("formats years ago", () => { + const timestamp = Date.now() - 400 * 24 * 60 * 60 * 1000; + expect(formatTimeAgo(timestamp)).toBe("1y ago"); + }); +}); diff --git a/tests/__tests__/components/Spinner.test.tsx b/tests/__tests__/components/Spinner.test.tsx new file mode 100644 index 00000000..559b26d8 --- /dev/null +++ b/tests/__tests__/components/Spinner.test.tsx @@ -0,0 +1,60 @@ +/** + * Tests for Spinner component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { SpinnerComponent } from '../../../src/components/Spinner.js'; + +describe('SpinnerComponent', () => { + it('renders without crashing', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays the message', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('Please wait'); + }); + + it('truncates long messages', () => { + const longMessage = 'A'.repeat(300); + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('...'); + expect(frame.length).toBeLessThan(longMessage.length); + }); + + it('handles empty message', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('handles message at max length', () => { + const maxLengthMessage = 'A'.repeat(200); + const { lastFrame } = render( + + ); + + // Should not truncate at exactly max length + expect(lastFrame()).not.toContain('...'); + }); + + it('handles message just over max length', () => { + const overMaxMessage = 'A'.repeat(201); + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('...'); + }); +}); + diff --git a/tests/__tests__/components/StatusBadge.test.tsx b/tests/__tests__/components/StatusBadge.test.tsx new file mode 100644 index 00000000..f312a734 --- /dev/null +++ b/tests/__tests__/components/StatusBadge.test.tsx @@ -0,0 +1,136 @@ +/** + * Tests for StatusBadge component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { StatusBadge, getStatusDisplay } from '../../../src/components/StatusBadge.js'; + +describe('StatusBadge', () => { + it('renders without crashing', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays running status', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('RUNNING'); + }); + + it('displays provisioning status', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('PROVISION'); + }); + + it('displays suspended status', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('SUSPENDED'); + }); + + it('displays failure status', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('FAILED'); + }); + + it('displays shutdown status', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('SHUTDOWN'); + }); + + it('hides text when showText is false', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).not.toContain('RUNNING'); + }); + + it('shows icon when showText is false', () => { + const { lastFrame } = render( + + ); + // Should still show the icon (circleFilled) + expect(lastFrame()).toContain('●'); + }); + + it('handles unknown status', () => { + const { lastFrame } = render( + + ); + // Should display padded status + expect(lastFrame()).toBeTruthy(); + }); + + it('handles empty status', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('UNKNOWN'); + }); +}); + +describe('getStatusDisplay', () => { + it('returns correct display for running', () => { + const display = getStatusDisplay('running'); + expect(display.text.trim()).toBe('RUNNING'); + expect(display.icon).toBe('●'); + }); + + it('returns correct display for provisioning', () => { + const display = getStatusDisplay('provisioning'); + expect(display.text.trim()).toBe('PROVISION'); + expect(display.icon).toBe('…'); + }); + + it('returns correct display for initializing', () => { + const display = getStatusDisplay('initializing'); + expect(display.text.trim()).toBe('INITIALIZE'); + }); + + it('returns correct display for suspended', () => { + const display = getStatusDisplay('suspended'); + expect(display.text.trim()).toBe('SUSPENDED'); + }); + + it('returns correct display for failure', () => { + const display = getStatusDisplay('failure'); + expect(display.text.trim()).toBe('FAILED'); + expect(display.icon).toBe('✗'); + }); + + it('returns correct display for resuming', () => { + const display = getStatusDisplay('resuming'); + expect(display.text.trim()).toBe('RESUMING'); + }); + + it('returns correct display for building', () => { + const display = getStatusDisplay('building'); + expect(display.text.trim()).toBe('BUILDING'); + }); + + it('returns correct display for build_complete', () => { + const display = getStatusDisplay('build_complete'); + expect(display.text.trim()).toBe('COMPLETE'); + }); + + it('returns unknown display for null status', () => { + const display = getStatusDisplay(null as unknown as string); + expect(display.text.trim()).toBe('UNKNOWN'); + }); + + it('truncates and pads unknown statuses', () => { + const display = getStatusDisplay('very_long_unknown_status'); + expect(display.text).toHaveLength(10); + }); +}); + diff --git a/tests/__tests__/components/SuccessMessage.test.tsx b/tests/__tests__/components/SuccessMessage.test.tsx new file mode 100644 index 00000000..6cd87ba7 --- /dev/null +++ b/tests/__tests__/components/SuccessMessage.test.tsx @@ -0,0 +1,98 @@ +/** + * Tests for SuccessMessage component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { SuccessMessage } from '../../../src/components/SuccessMessage.js'; + +describe('SuccessMessage', () => { + it('renders without crashing', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays the success message', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('Operation completed successfully'); + }); + + it('shows tick icon', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('✓'); + }); + + it('displays details when provided', () => { + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Created successfully'); + expect(frame).toContain('ID: test-123'); + }); + + it('handles multi-line details', () => { + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Line 1'); + expect(frame).toContain('Line 2'); + expect(frame).toContain('Line 3'); + }); + + it('truncates long messages', () => { + const longMessage = 'A'.repeat(600); + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('...'); + // Frame should not contain the full untruncated message + expect(frame).not.toContain(longMessage); + }); + + it('truncates long detail lines', () => { + const longDetails = 'B'.repeat(600); + const { lastFrame } = render( + + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('...'); + }); + + it('handles empty details gracefully', () => { + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('Success'); + }); + + it('handles empty message', () => { + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('✓'); + }); +}); + diff --git a/tests/__tests__/components/Table.test.tsx b/tests/__tests__/components/Table.test.tsx new file mode 100644 index 00000000..e6b3642f --- /dev/null +++ b/tests/__tests__/components/Table.test.tsx @@ -0,0 +1,216 @@ +/** + * Tests for Table component + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { Table, createTextColumn, createComponentColumn, Column } from '../../../src/components/Table.js'; +import { Text } from 'ink'; + +interface TestRow { + id: string; + name: string; + status: string; +} + +describe('Table', () => { + const testData: TestRow[] = [ + { id: '1', name: 'Item 1', status: 'active' }, + { id: '2', name: 'Item 2', status: 'inactive' }, + ]; + + const testColumns: Column[] = [ + createTextColumn('name', 'Name', (row) => row.name, { width: 15 }), + createTextColumn('status', 'Status', (row) => row.status, { width: 10 }), + ]; + + it('renders without crashing', () => { + const { lastFrame } = render( + row.id} + /> + ); + expect(lastFrame()).toBeTruthy(); + }); + + it('displays column headers', () => { + const { lastFrame } = render( +
row.id} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Name'); + expect(frame).toContain('Status'); + }); + + it('displays row data', () => { + const { lastFrame } = render( +
row.id} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Item 1'); + expect(frame).toContain('Item 2'); + expect(frame).toContain('active'); + expect(frame).toContain('inactive'); + }); + + it('shows selection pointer', () => { + const { lastFrame } = render( +
row.id} + selectedIndex={0} + showSelection={true} + /> + ); + + expect(lastFrame()).toContain('❯'); + }); + + it('hides selection pointer when showSelection is false', () => { + const { lastFrame } = render( +
row.id} + selectedIndex={0} + showSelection={false} + /> + ); + + expect(lastFrame()).not.toContain('❯'); + }); + + it('displays title when provided', () => { + const { lastFrame } = render( +
row.id} + title="My Table" + /> + ); + + expect(lastFrame()).toContain('My Table'); + }); + + it('shows empty state when provided', () => { + const emptyState = No data available; + + const { lastFrame } = render( +
row.id} + emptyState={emptyState} + /> + ); + + expect(lastFrame()).toContain('No data available'); + }); + + it('handles null data gracefully', () => { + const { lastFrame } = render( +
row.id} + emptyState={Empty} + /> + ); + + expect(lastFrame()).toContain('Empty'); + }); + + it('filters hidden columns', () => { + const columnsWithHidden: Column[] = [ + createTextColumn('name', 'Name', (row) => row.name, { width: 15, visible: true }), + createTextColumn('status', 'Status', (row) => row.status, { width: 10, visible: false }), + ]; + + const { lastFrame } = render( +
row.id} + /> + ); + + const frame = lastFrame() || ''; + expect(frame).toContain('Name'); + expect(frame).not.toContain('Status'); + }); +}); + +describe('createTextColumn', () => { + it('creates a valid column definition', () => { + const column = createTextColumn('test', 'Test', (row: { value: string }) => row.value); + + expect(column.key).toBe('test'); + expect(column.label).toBe('Test'); + expect(column.width).toBe(20); // default + }); + + it('respects custom width', () => { + const column = createTextColumn('test', 'Test', () => 'value', { width: 30 }); + expect(column.width).toBe(30); + }); + + it('truncates long values', () => { + const column = createTextColumn('test', 'Test', () => 'A'.repeat(50), { width: 10 }); + const rendered = column.render({ value: 'test' }, 0, false); + + // Should be a React element + expect(rendered).toBeTruthy(); + }); +}); + +describe('createComponentColumn', () => { + it('creates a valid column definition', () => { + const column = createComponentColumn( + 'custom', + 'Custom', + () => Custom + ); + + expect(column.key).toBe('custom'); + expect(column.label).toBe('Custom'); + expect(column.width).toBe(20); // default + }); + + it('respects custom width', () => { + const column = createComponentColumn( + 'custom', + 'Custom', + () => Custom, + { width: 25 } + ); + + expect(column.width).toBe(25); + }); + + it('renders custom component', () => { + const column = createComponentColumn( + 'badge', + 'Badge', + (_row, _index, isSelected) => ( + {isSelected ? '[X]' : '[ ]'} + ) + ); + + const rendered = column.render({}, 0, true); + expect(rendered).toBeTruthy(); + }); +}); + diff --git a/tests/__tests__/components/UpdateNotification.test.tsx b/tests/__tests__/components/UpdateNotification.test.tsx new file mode 100644 index 00000000..27124fb7 --- /dev/null +++ b/tests/__tests__/components/UpdateNotification.test.tsx @@ -0,0 +1,105 @@ +/** + * Tests for UpdateNotification component + */ +import React from 'react'; +import { jest } from '@jest/globals'; +import { render } from 'ink-testing-library'; +import { UpdateNotification } from '../../../src/components/UpdateNotification.js'; + +// Mock fetch +global.fetch = jest.fn() as jest.Mock; + +describe('UpdateNotification', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '0.1.0' }), + }); + + const { lastFrame } = render(); + expect(lastFrame()).toBeDefined(); + }); + + it('shows nothing while checking', () => { + (global.fetch as jest.Mock).mockImplementation(() => + new Promise(() => {}) // Never resolves + ); + + const { lastFrame } = render(); + // Should be empty while checking + expect(lastFrame()).toBe(''); + }); + + it('shows nothing when on latest version', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '0.1.0' }), // Same as current + }); + + const { lastFrame } = render(); + + // Wait for effect to run + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(lastFrame()).toBe(''); + }); + + it('shows nothing on fetch error', async () => { + (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + + const { lastFrame } = render(); + + // Wait for effect to run + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(lastFrame()).toBe(''); + }); + + it('shows update notification when newer version available', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '99.99.99' }), // Much higher version + }); + + const { lastFrame } = render(); + + // Wait for effect to run + await new Promise((resolve) => setTimeout(resolve, 100)); + + const frame = lastFrame() || ''; + // Should show update notification + expect(frame).toContain('Update available'); + expect(frame).toContain('99.99.99'); + }); + + it('shows npm install command', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '99.99.99' }), + }); + + const { lastFrame } = render(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(lastFrame()).toContain('npm install -g @runloop/rl-cli@latest'); + }); + + it('handles non-ok response', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const { lastFrame } = render(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(lastFrame()).toBe(''); + }); +}); + diff --git a/tests/__tests__/integration/blueprint.e2e.test.js b/tests/__tests__/integration/blueprint.e2e.test.js deleted file mode 100644 index 5ec32807..00000000 --- a/tests/__tests__/integration/blueprint.e2e.test.js +++ /dev/null @@ -1,123 +0,0 @@ -import { exec } from 'child_process'; -import { promisify } from 'util'; -const execAsync = promisify(exec); -describe('Blueprint E2E Tests', () => { - const apiKey = process.env.RUNLOOP_API_KEY; - beforeAll(() => { - if (!apiKey) { - console.log('Skipping E2E tests: RUNLOOP_API_KEY not set'); - } - }); - beforeEach(() => { - if (!apiKey) { - pending('RUNLOOP_API_KEY required for E2E tests'); - } - }); - describe('Blueprint Lifecycle', () => { - let blueprintId; - it('should create a blueprint', async () => { - const { stdout } = await execAsync('node dist/cli.js blueprint create test-blueprint --dockerfile "FROM ubuntu:20.04" --system-setup-commands "apt update" --resources SMALL --output json'); - // Extract blueprint ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - blueprintId = match[1]; - expect(blueprintId).toBeDefined(); - }, 60000); - it('should get blueprint details', async () => { - expect(blueprintId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js blueprint get ${blueprintId} --output json`); - expect(stdout).toContain('"id":'); - expect(stdout).toContain(blueprintId); - }, 30000); - it('should list blueprints', async () => { - const { stdout } = await execAsync('node dist/cli.js blueprint list --output json'); - expect(stdout).toContain('"id":'); - }, 30000); - it('should preview blueprint', async () => { - const { stdout } = await execAsync('node dist/cli.js blueprint preview test-blueprint-preview --dockerfile "FROM ubuntu:20.04" --resources SMALL --output json'); - expect(stdout).toContain('"dockerfile":'); - }, 30000); - }); - describe('Blueprint with Dockerfile File', () => { - let blueprintId; - let dockerfilePath; - beforeAll(async () => { - if (!apiKey) - return; - // Create a temporary Dockerfile - const fs = require('fs'); - const path = require('path'); - const os = require('os'); - dockerfilePath = path.join(os.tmpdir(), 'Dockerfile'); - fs.writeFileSync(dockerfilePath, 'FROM ubuntu:20.04\nRUN apt update\nRUN apt install -y git'); - }); - afterAll(async () => { - if (dockerfilePath) { - try { - const fs = require('fs'); - fs.unlinkSync(dockerfilePath); - } - catch (error) { - console.warn('Failed to cleanup Dockerfile:', error); - } - } - }); - it('should create blueprint with dockerfile file', async () => { - const { stdout } = await execAsync(`node dist/cli.js blueprint create test-blueprint-file --dockerfile-path ${dockerfilePath} --resources SMALL --output json`); - // Extract blueprint ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - blueprintId = match[1]; - expect(blueprintId).toBeDefined(); - }, 60000); - }); - describe('Blueprint with User Parameters', () => { - let blueprintId; - it('should create blueprint with root user', async () => { - const { stdout } = await execAsync('node dist/cli.js blueprint create test-blueprint-root --dockerfile "FROM ubuntu:20.04" --root --resources SMALL --output json'); - // Extract blueprint ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - blueprintId = match[1]; - expect(blueprintId).toBeDefined(); - }, 60000); - it('should create blueprint with custom user', async () => { - const { stdout } = await execAsync('node dist/cli.js blueprint create test-blueprint-user --dockerfile "FROM ubuntu:20.04" --user testuser:1000 --resources SMALL --output json'); - // Extract blueprint ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - expect(match[1]).toBeDefined(); - }, 60000); - }); - describe('Blueprint with Advanced Options', () => { - let blueprintId; - it('should create blueprint with architecture and ports', async () => { - const { stdout } = await execAsync('node dist/cli.js blueprint create test-blueprint-advanced --dockerfile "FROM ubuntu:20.04" --architecture arm64 --available-ports 3000,8080 --resources MEDIUM --output json'); - // Extract blueprint ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - blueprintId = match[1]; - expect(blueprintId).toBeDefined(); - }, 60000); - }); - describe('Blueprint Build Logs', () => { - let blueprintId; - beforeAll(async () => { - if (!apiKey) - return; - // Create a blueprint for logs - const { stdout } = await execAsync('node dist/cli.js blueprint create test-blueprint-logs --dockerfile "FROM ubuntu:20.04" --system-setup-commands "apt update" --resources SMALL --output json'); - const match = stdout.match(/"id":\s*"([^"]+)"/); - if (match) { - blueprintId = match[1]; - } - }, 60000); - it('should retrieve blueprint build logs', async () => { - if (!blueprintId) { - pending('Blueprint not created'); - } - const { stdout } = await execAsync(`node dist/cli.js blueprint logs ${blueprintId} --output json`); - expect(stdout).toContain('"logs":'); - }, 30000); - }); -}); diff --git a/tests/__tests__/integration/blueprint.e2e.test.ts b/tests/__tests__/integration/blueprint.e2e.test.ts deleted file mode 100644 index 2c9593d3..00000000 --- a/tests/__tests__/integration/blueprint.e2e.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { jest } from '@jest/globals'; -import { exec } from 'child_process'; -import { promisify } from 'util'; - -const execAsync = promisify(exec); - -describe('Blueprint E2E Tests', () => { - const apiKey = process.env.RUNLOOP_API_KEY; - - beforeAll(() => { - if (!apiKey) { - console.log('Skipping E2E tests: RUNLOOP_API_KEY not set'); - } - }); - - beforeEach(() => { - if (!apiKey) { - pending('RUNLOOP_API_KEY required for E2E tests'); - } - }); - - describe('Blueprint Lifecycle', () => { - let blueprintId: string; - - it('should create a blueprint', async () => { - const { stdout } = await execAsync('node dist/cli.js blueprint create test-blueprint --dockerfile "FROM ubuntu:20.04" --system-setup-commands "apt update" --resources SMALL --output json'); - - // Extract blueprint ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - blueprintId = match![1]; - expect(blueprintId).toBeDefined(); - }, 60000); - - it('should get blueprint details', async () => { - expect(blueprintId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js blueprint get ${blueprintId} --output json`); - - expect(stdout).toContain('"id":'); - expect(stdout).toContain(blueprintId); - }, 30000); - - it('should list blueprints', async () => { - const { stdout } = await execAsync('node dist/cli.js blueprint list --output json'); - - expect(stdout).toContain('"id":'); - }, 30000); - - it('should preview blueprint', async () => { - const { stdout } = await execAsync('node dist/cli.js blueprint preview test-blueprint-preview --dockerfile "FROM ubuntu:20.04" --resources SMALL --output json'); - - expect(stdout).toContain('"dockerfile":'); - }, 30000); - }); - - describe('Blueprint with Dockerfile File', () => { - let blueprintId: string; - let dockerfilePath: string; - - beforeAll(async () => { - if (!apiKey) return; - - // Create a temporary Dockerfile - const fs = require('fs'); - const path = require('path'); - const os = require('os'); - - dockerfilePath = path.join(os.tmpdir(), 'Dockerfile'); - fs.writeFileSync(dockerfilePath, 'FROM ubuntu:20.04\nRUN apt update\nRUN apt install -y git'); - }); - - afterAll(async () => { - if (dockerfilePath) { - try { - const fs = require('fs'); - fs.unlinkSync(dockerfilePath); - } catch (error) { - console.warn('Failed to cleanup Dockerfile:', error); - } - } - }); - - it('should create blueprint with dockerfile file', async () => { - const { stdout } = await execAsync(`node dist/cli.js blueprint create test-blueprint-file --dockerfile-path ${dockerfilePath} --resources SMALL --output json`); - - // Extract blueprint ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - blueprintId = match![1]; - expect(blueprintId).toBeDefined(); - }, 60000); - }); - - describe('Blueprint with User Parameters', () => { - let blueprintId: string; - - it('should create blueprint with root user', async () => { - const { stdout } = await execAsync('node dist/cli.js blueprint create test-blueprint-root --dockerfile "FROM ubuntu:20.04" --root --resources SMALL --output json'); - - // Extract blueprint ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - blueprintId = match![1]; - expect(blueprintId).toBeDefined(); - }, 60000); - - it('should create blueprint with custom user', async () => { - const { stdout } = await execAsync('node dist/cli.js blueprint create test-blueprint-user --dockerfile "FROM ubuntu:20.04" --user testuser:1000 --resources SMALL --output json'); - - // Extract blueprint ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - expect(match![1]).toBeDefined(); - }, 60000); - }); - - describe('Blueprint with Advanced Options', () => { - let blueprintId: string; - - it('should create blueprint with architecture and ports', async () => { - const { stdout } = await execAsync('node dist/cli.js blueprint create test-blueprint-advanced --dockerfile "FROM ubuntu:20.04" --architecture arm64 --available-ports 3000,8080 --resources MEDIUM --output json'); - - // Extract blueprint ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - blueprintId = match![1]; - expect(blueprintId).toBeDefined(); - }, 60000); - }); - - describe('Blueprint Build Logs', () => { - let blueprintId: string; - - beforeAll(async () => { - if (!apiKey) return; - - // Create a blueprint for logs - const { stdout } = await execAsync('node dist/cli.js blueprint create test-blueprint-logs --dockerfile "FROM ubuntu:20.04" --system-setup-commands "apt update" --resources SMALL --output json'); - const match = stdout.match(/"id":\s*"([^"]+)"/); - if (match) { - blueprintId = match[1]; - } - }, 60000); - - it('should retrieve blueprint build logs', async () => { - if (!blueprintId) { - pending('Blueprint not created'); - } - - const { stdout } = await execAsync(`node dist/cli.js blueprint logs ${blueprintId} --output json`); - - expect(stdout).toContain('"logs":'); - }, 30000); - }); -}); diff --git a/tests/__tests__/integration/cli-defaults.e2e.test.js b/tests/__tests__/integration/cli-defaults.e2e.test.js deleted file mode 100644 index f4956d1d..00000000 --- a/tests/__tests__/integration/cli-defaults.e2e.test.js +++ /dev/null @@ -1,151 +0,0 @@ -import { promisify } from "util"; -import { exec } from "child_process"; -const execAsync = promisify(exec); -describe("CLI Default Output Behavior", () => { - const CLI_PATH = "dist/cli.js"; - // Mock API responses for consistent testing - const mockDevboxList = [ - { - id: "dbx_test123", - name: "test-devbox", - status: "running", - create_time_ms: 1640995200000, - } - ]; - const mockSnapshotList = [ - { - id: "snap_test123", - name: "test-snapshot", - status: "complete", - create_time_ms: 1640995200000, - } - ]; - const mockBlueprintList = [ - { - id: "bp_test123", - name: "test-blueprint", - status: "build_complete", - create_time_ms: 1640995200000, - } - ]; - const mockObjectList = [ - { - id: "obj_test123", - name: "test-object", - content_type: "text", - size: 1024, - } - ]; - describe("Default JSON Output", () => { - it("should output JSON for devbox list by default", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list`); - // Should be valid JSON - const output = JSON.parse(stdout); - expect(Array.isArray(output)).toBe(true); - }, 10000); - it("should output JSON for snapshot list by default", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} snapshot list`); - // Should be valid JSON - const output = JSON.parse(stdout); - expect(Array.isArray(output)).toBe(true); - }, 10000); - it("should output JSON for blueprint list by default", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} blueprint list`); - // Should be valid JSON - const output = JSON.parse(stdout); - expect(Array.isArray(output)).toBe(true); - }, 10000); - it("should output JSON for object list by default", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} object list`); - // Should be valid JSON - const output = JSON.parse(stdout); - expect(Array.isArray(output)).toBe(true); - }, 10000); - }); - describe("Explicit Format Override", () => { - it("should output text format when -o text is specified", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list -o text`); - // Should not be JSON (text format) - expect(() => JSON.parse(stdout)).toThrow(); - expect(stdout.trim()).not.toBe(""); - }, 10000); - it("should output YAML format when -o yaml is specified", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list -o yaml`); - // Should not be JSON (YAML format) - expect(() => JSON.parse(stdout)).toThrow(); - expect(stdout.trim()).not.toBe(""); - }, 10000); - it("should output JSON format when -o json is explicitly specified", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list -o json`); - // Should be valid JSON - const output = JSON.parse(stdout); - expect(Array.isArray(output)).toBe(true); - }, 10000); - }); - describe("Get Commands Default to JSON", () => { - it("should output JSON for devbox get by default", async () => { - // This will likely fail due to invalid ID, but should still attempt JSON output - try { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox get invalid-id`); - // If it succeeds, should be JSON - JSON.parse(stdout); - } - catch (error) { - // Expected to fail, but should not be interactive mode - expect(error.message).not.toContain("Raw mode is not supported"); - } - }, 10000); - it("should output JSON for blueprint get by default", async () => { - try { - const { stdout } = await execAsync(`node ${CLI_PATH} blueprint get invalid-id`); - JSON.parse(stdout); - } - catch (error) { - expect(error.message).not.toContain("Raw mode is not supported"); - } - }, 10000); - }); - describe("Create Commands Default to JSON", () => { - it("should output JSON for devbox create by default", async () => { - try { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox create --name test-devbox`); - // Should be JSON output (likely an error due to missing template) - JSON.parse(stdout); - } - catch (error) { - expect(error.message).not.toContain("Raw mode is not supported"); - } - }, 10000); - }); - describe("Help Commands", () => { - it("should show help for devbox list command", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list --help`); - expect(stdout).toContain("List all devboxes"); - expect(stdout).toContain("Output format: text|json|yaml (default: json)"); - }); - it("should show help for snapshot list command", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} snapshot list --help`); - expect(stdout).toContain("List all snapshots"); - expect(stdout).toContain("Output format: text|json|yaml (default: json)"); - }); - it("should show help for blueprint list command", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} blueprint list --help`); - expect(stdout).toContain("List all blueprints"); - expect(stdout).toContain("Output format: text|json|yaml (default: json)"); - }); - }); - describe("Error Handling", () => { - it("should handle API errors gracefully in JSON format", async () => { - try { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox get nonexistent-id`); - // Should be JSON error response - const output = JSON.parse(stdout); - expect(output).toHaveProperty("error"); - } - catch (error) { - // Should not be interactive mode error - expect(error.message).not.toContain("Raw mode is not supported"); - } - }, 10000); - }); -}); diff --git a/tests/__tests__/integration/cli-defaults.e2e.test.ts b/tests/__tests__/integration/cli-defaults.e2e.test.ts deleted file mode 100644 index 5ec1c98b..00000000 --- a/tests/__tests__/integration/cli-defaults.e2e.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { spawn } from "child_process"; -import { promisify } from "util"; -import { exec } from "child_process"; - -const execAsync = promisify(exec); - -describe("CLI Default Output Behavior", () => { - const CLI_PATH = "dist/cli.js"; - - // Mock API responses for consistent testing - const mockDevboxList = [ - { - id: "dbx_test123", - name: "test-devbox", - status: "running", - create_time_ms: 1640995200000, - } - ]; - - const mockSnapshotList = [ - { - id: "snap_test123", - name: "test-snapshot", - status: "complete", - create_time_ms: 1640995200000, - } - ]; - - const mockBlueprintList = [ - { - id: "bp_test123", - name: "test-blueprint", - status: "build_complete", - create_time_ms: 1640995200000, - } - ]; - - const mockObjectList = [ - { - id: "obj_test123", - name: "test-object", - content_type: "text", - size: 1024, - } - ]; - - describe("Default JSON Output", () => { - it("should output JSON for devbox list by default", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list`); - - // Should be valid JSON - const output = JSON.parse(stdout); - expect(Array.isArray(output)).toBe(true); - }, 10000); - - it("should output JSON for snapshot list by default", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} snapshot list`); - - // Should be valid JSON - const output = JSON.parse(stdout); - expect(Array.isArray(output)).toBe(true); - }, 10000); - - it("should output JSON for blueprint list by default", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} blueprint list`); - - // Should be valid JSON - const output = JSON.parse(stdout); - expect(Array.isArray(output)).toBe(true); - }, 10000); - - it("should output JSON for object list by default", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} object list`); - - // Should be valid JSON - const output = JSON.parse(stdout); - expect(Array.isArray(output)).toBe(true); - }, 10000); - }); - - describe("Explicit Format Override", () => { - it("should output text format when -o text is specified", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list -o text`); - - // Should not be JSON (text format) - expect(() => JSON.parse(stdout)).toThrow(); - expect(stdout.trim()).not.toBe(""); - }, 10000); - - it("should output YAML format when -o yaml is specified", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list -o yaml`); - - // Should not be JSON (YAML format) - expect(() => JSON.parse(stdout)).toThrow(); - expect(stdout.trim()).not.toBe(""); - }, 10000); - - it("should output JSON format when -o json is explicitly specified", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list -o json`); - - // Should be valid JSON - const output = JSON.parse(stdout); - expect(Array.isArray(output)).toBe(true); - }, 10000); - }); - - describe("Get Commands Default to JSON", () => { - it("should output JSON for devbox get by default", async () => { - // This will likely fail due to invalid ID, but should still attempt JSON output - try { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox get invalid-id`); - // If it succeeds, should be JSON - JSON.parse(stdout); - } catch (error) { - // Expected to fail, but should not be interactive mode - expect((error as Error).message).not.toContain("Raw mode is not supported"); - } - }, 10000); - - it("should output JSON for blueprint get by default", async () => { - try { - const { stdout } = await execAsync(`node ${CLI_PATH} blueprint get invalid-id`); - JSON.parse(stdout); - } catch (error) { - expect((error as Error).message).not.toContain("Raw mode is not supported"); - } - }, 10000); - }); - - describe("Create Commands Default to JSON", () => { - it("should output JSON for devbox create by default", async () => { - try { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox create --name test-devbox`); - // Should be JSON output (likely an error due to missing template) - JSON.parse(stdout); - } catch (error) { - expect((error as Error).message).not.toContain("Raw mode is not supported"); - } - }, 10000); - }); - - describe("Help Commands", () => { - it("should show help for devbox list command", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list --help`); - - expect(stdout).toContain("List all devboxes"); - expect(stdout).toContain("Output format: text|json|yaml (default: json)"); - }); - - it("should show help for snapshot list command", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} snapshot list --help`); - - expect(stdout).toContain("List all snapshots"); - expect(stdout).toContain("Output format: text|json|yaml (default: json)"); - }); - - it("should show help for blueprint list command", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} blueprint list --help`); - - expect(stdout).toContain("List all blueprints"); - expect(stdout).toContain("Output format: text|json|yaml (default: json)"); - }); - }); - - describe("Error Handling", () => { - it("should handle API errors gracefully in JSON format", async () => { - try { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox get nonexistent-id`); - // Should be JSON error response - const output = JSON.parse(stdout); - expect(output).toHaveProperty("error"); - } catch (error) { - // Should not be interactive mode error - expect((error as Error).message).not.toContain("Raw mode is not supported"); - } - }, 10000); - }); -}); diff --git a/tests/__tests__/integration/cli-interactive.e2e.test.js b/tests/__tests__/integration/cli-interactive.e2e.test.js deleted file mode 100644 index 6495e19e..00000000 --- a/tests/__tests__/integration/cli-interactive.e2e.test.js +++ /dev/null @@ -1,200 +0,0 @@ -import { spawn } from "child_process"; -import { promisify } from "util"; -import { exec } from "child_process"; -const execAsync = promisify(exec); -describe("CLI Interactive Mode", () => { - const CLI_PATH = "dist/cli.js"; - describe("Top-Level Commands Use Interactive Mode", () => { - it("should trigger interactive mode for 'rli devbox'", async () => { - const process = spawn("node", [CLI_PATH, "devbox"], { - stdio: ["pipe", "pipe", "pipe"], - timeout: 5000, // 5 second timeout - }); - let output = ""; - let hasAlternateScreenBuffer = false; - process.stdout.on("data", (data) => { - output += data.toString(); - // Check for alternate screen buffer escape sequence - if (data.toString().includes("\x1b[?1049h")) { - hasAlternateScreenBuffer = true; - } - }); - process.stderr.on("data", (data) => { - output += data.toString(); - }); - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - process.kill(); - reject(new Error("Process timeout")); - }, 5000); - process.on("close", (code) => { - clearTimeout(timeout); - // Interactive mode should be triggered (alternate screen buffer) - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - }); - process.on("error", (error) => { - clearTimeout(timeout); - // If it's a raw mode error, that's expected in test environment - if (error.message.includes("Raw mode is not supported")) { - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - } - else { - reject(error); - } - }); - // Send Ctrl+C to exit - setTimeout(() => { - process.kill("SIGINT"); - }, 1000); - }); - }, 10000); - it("should trigger interactive mode for 'rli snapshot'", async () => { - const process = spawn("node", [CLI_PATH, "snapshot"], { - stdio: ["pipe", "pipe", "pipe"], - timeout: 5000, - }); - let hasAlternateScreenBuffer = false; - process.stdout.on("data", (data) => { - if (data.toString().includes("\x1b[?1049h")) { - hasAlternateScreenBuffer = true; - } - }); - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - process.kill(); - reject(new Error("Process timeout")); - }, 5000); - process.on("close", (code) => { - clearTimeout(timeout); - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - }); - process.on("error", (error) => { - clearTimeout(timeout); - if (error.message.includes("Raw mode is not supported")) { - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - } - else { - reject(error); - } - }); - setTimeout(() => { - process.kill("SIGINT"); - }, 1000); - }); - }, 10000); - it("should trigger interactive mode for 'rli blueprint'", async () => { - const process = spawn("node", [CLI_PATH, "blueprint"], { - stdio: ["pipe", "pipe", "pipe"], - timeout: 5000, - }); - let hasAlternateScreenBuffer = false; - process.stdout.on("data", (data) => { - if (data.toString().includes("\x1b[?1049h")) { - hasAlternateScreenBuffer = true; - } - }); - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - process.kill(); - reject(new Error("Process timeout")); - }, 5000); - process.on("close", (code) => { - clearTimeout(timeout); - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - }); - process.on("error", (error) => { - clearTimeout(timeout); - if (error.message.includes("Raw mode is not supported")) { - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - } - else { - reject(error); - } - }); - setTimeout(() => { - process.kill("SIGINT"); - }, 1000); - }); - }, 10000); - }); - describe("Subcommands Use Non-Interactive Mode", () => { - it("should use non-interactive mode for 'rli devbox list'", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list`); - // Should output JSON, not trigger interactive mode - expect(() => JSON.parse(stdout)).not.toThrow(); - }, 10000); - it("should use non-interactive mode for 'rli snapshot list'", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} snapshot list`); - // Should output JSON, not trigger interactive mode - expect(() => JSON.parse(stdout)).not.toThrow(); - }, 10000); - it("should use non-interactive mode for 'rli blueprint list'", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} blueprint list`); - // Should output JSON, not trigger interactive mode - expect(() => JSON.parse(stdout)).not.toThrow(); - }, 10000); - }); - describe("Backward Compatibility", () => { - it("should maintain interactive mode for main menu (no args)", async () => { - const process = spawn("node", [CLI_PATH], { - stdio: ["pipe", "pipe", "pipe"], - timeout: 5000, - }); - let hasAlternateScreenBuffer = false; - process.stdout.on("data", (data) => { - if (data.toString().includes("\x1b[?1049h")) { - hasAlternateScreenBuffer = true; - } - }); - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - process.kill(); - reject(new Error("Process timeout")); - }, 5000); - process.on("close", (code) => { - clearTimeout(timeout); - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - }); - process.on("error", (error) => { - clearTimeout(timeout); - if (error.message.includes("Raw mode is not supported")) { - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - } - else { - reject(error); - } - }); - setTimeout(() => { - process.kill("SIGINT"); - }, 1000); - }); - }, 10000); - }); - describe("Output Format Detection", () => { - it("should detect JSON output format", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list`); - const output = JSON.parse(stdout); - expect(Array.isArray(output)).toBe(true); - }, 10000); - it("should detect text output format", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list -o text`); - // Should not be JSON - expect(() => JSON.parse(stdout)).toThrow(); - expect(stdout.trim()).not.toBe(""); - }, 10000); - it("should detect YAML output format", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list -o yaml`); - // Should not be JSON - expect(() => JSON.parse(stdout)).toThrow(); - expect(stdout.trim()).not.toBe(""); - }, 10000); - }); -}); diff --git a/tests/__tests__/integration/cli-interactive.e2e.test.ts b/tests/__tests__/integration/cli-interactive.e2e.test.ts deleted file mode 100644 index 9f9a9019..00000000 --- a/tests/__tests__/integration/cli-interactive.e2e.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { spawn } from "child_process"; -import { promisify } from "util"; -import { exec } from "child_process"; - -const execAsync = promisify(exec); - -describe("CLI Interactive Mode", () => { - const CLI_PATH = "dist/cli.js"; - - describe("Top-Level Commands Use Interactive Mode", () => { - it("should trigger interactive mode for 'rli devbox'", async () => { - const process = spawn("node", [CLI_PATH, "devbox"], { - stdio: ["pipe", "pipe", "pipe"], - timeout: 5000, // 5 second timeout - }); - - let output = ""; - let hasAlternateScreenBuffer = false; - - process.stdout.on("data", (data) => { - output += data.toString(); - // Check for alternate screen buffer escape sequence - if (data.toString().includes("\x1b[?1049h")) { - hasAlternateScreenBuffer = true; - } - }); - - process.stderr.on("data", (data) => { - output += data.toString(); - }); - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - process.kill(); - reject(new Error("Process timeout")); - }, 5000); - - process.on("close", (code) => { - clearTimeout(timeout); - // Interactive mode should be triggered (alternate screen buffer) - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - }); - - process.on("error", (error) => { - clearTimeout(timeout); - // If it's a raw mode error, that's expected in test environment - if (error.message.includes("Raw mode is not supported")) { - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - } else { - reject(error); - } - }); - - // Send Ctrl+C to exit - setTimeout(() => { - process.kill("SIGINT"); - }, 1000); - }); - }, 10000); - - it("should trigger interactive mode for 'rli snapshot'", async () => { - const process = spawn("node", [CLI_PATH, "snapshot"], { - stdio: ["pipe", "pipe", "pipe"], - timeout: 5000, - }); - - let hasAlternateScreenBuffer = false; - - process.stdout.on("data", (data) => { - if (data.toString().includes("\x1b[?1049h")) { - hasAlternateScreenBuffer = true; - } - }); - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - process.kill(); - reject(new Error("Process timeout")); - }, 5000); - - process.on("close", (code) => { - clearTimeout(timeout); - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - }); - - process.on("error", (error) => { - clearTimeout(timeout); - if (error.message.includes("Raw mode is not supported")) { - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - } else { - reject(error); - } - }); - - setTimeout(() => { - process.kill("SIGINT"); - }, 1000); - }); - }, 10000); - - it("should trigger interactive mode for 'rli blueprint'", async () => { - const process = spawn("node", [CLI_PATH, "blueprint"], { - stdio: ["pipe", "pipe", "pipe"], - timeout: 5000, - }); - - let hasAlternateScreenBuffer = false; - - process.stdout.on("data", (data) => { - if (data.toString().includes("\x1b[?1049h")) { - hasAlternateScreenBuffer = true; - } - }); - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - process.kill(); - reject(new Error("Process timeout")); - }, 5000); - - process.on("close", (code) => { - clearTimeout(timeout); - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - }); - - process.on("error", (error) => { - clearTimeout(timeout); - if (error.message.includes("Raw mode is not supported")) { - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - } else { - reject(error); - } - }); - - setTimeout(() => { - process.kill("SIGINT"); - }, 1000); - }); - }, 10000); - }); - - describe("Subcommands Use Non-Interactive Mode", () => { - it("should use non-interactive mode for 'rli devbox list'", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list`); - - // Should output JSON, not trigger interactive mode - expect(() => JSON.parse(stdout)).not.toThrow(); - }, 10000); - - it("should use non-interactive mode for 'rli snapshot list'", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} snapshot list`); - - // Should output JSON, not trigger interactive mode - expect(() => JSON.parse(stdout)).not.toThrow(); - }, 10000); - - it("should use non-interactive mode for 'rli blueprint list'", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} blueprint list`); - - // Should output JSON, not trigger interactive mode - expect(() => JSON.parse(stdout)).not.toThrow(); - }, 10000); - }); - - describe("Backward Compatibility", () => { - - it("should maintain interactive mode for main menu (no args)", async () => { - const process = spawn("node", [CLI_PATH], { - stdio: ["pipe", "pipe", "pipe"], - timeout: 5000, - }); - - let hasAlternateScreenBuffer = false; - - process.stdout.on("data", (data) => { - if (data.toString().includes("\x1b[?1049h")) { - hasAlternateScreenBuffer = true; - } - }); - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - process.kill(); - reject(new Error("Process timeout")); - }, 5000); - - process.on("close", (code) => { - clearTimeout(timeout); - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - }); - - process.on("error", (error) => { - clearTimeout(timeout); - if (error.message.includes("Raw mode is not supported")) { - expect(hasAlternateScreenBuffer).toBe(true); - resolve(); - } else { - reject(error); - } - }); - - setTimeout(() => { - process.kill("SIGINT"); - }, 1000); - }); - }, 10000); - }); - - describe("Output Format Detection", () => { - it("should detect JSON output format", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list`); - - const output = JSON.parse(stdout); - expect(Array.isArray(output)).toBe(true); - }, 10000); - - it("should detect text output format", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list -o text`); - - // Should not be JSON - expect(() => JSON.parse(stdout)).toThrow(); - expect(stdout.trim()).not.toBe(""); - }, 10000); - - it("should detect YAML output format", async () => { - const { stdout } = await execAsync(`node ${CLI_PATH} devbox list -o yaml`); - - // Should not be JSON - expect(() => JSON.parse(stdout)).toThrow(); - expect(stdout.trim()).not.toBe(""); - }, 10000); - }); -}); diff --git a/tests/__tests__/integration/devbox.e2e.test.js b/tests/__tests__/integration/devbox.e2e.test.js deleted file mode 100644 index 8b45da57..00000000 --- a/tests/__tests__/integration/devbox.e2e.test.js +++ /dev/null @@ -1,200 +0,0 @@ -import { exec } from 'child_process'; -import { promisify } from 'util'; -const execAsync = promisify(exec); -// Helper function to wait for devbox to be ready -async function waitForDevboxReady(devboxId, maxWaitTime = 300000) { - const startTime = Date.now(); - const pollInterval = 10000; // 10 seconds - while (Date.now() - startTime < maxWaitTime) { - try { - const { stdout } = await execAsync(`node dist/cli.js devbox get ${devboxId} --output json`); - const devbox = JSON.parse(stdout); - if (devbox.status === 'running') { - console.log(`Devbox ${devboxId} is ready!`); - return; - } - console.log(`Devbox ${devboxId} status: ${devbox.status}, waiting...`); - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - catch (error) { - console.log(`Error checking devbox status: ${error}`); - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - } - throw new Error(`Devbox ${devboxId} did not become ready within ${maxWaitTime}ms`); -} -describe('Devbox E2E Tests', () => { - const apiKey = process.env.RUNLOOP_API_KEY; - beforeAll(() => { - if (!apiKey) { - console.log('Skipping E2E tests: RUNLOOP_API_KEY not set'); - } - }); - beforeEach(() => { - if (!apiKey) { - pending('RUNLOOP_API_KEY required for E2E tests'); - } - }); - describe('Devbox Lifecycle', () => { - let devboxId; - it('should create a devbox', async () => { - const { stdout } = await execAsync('node dist/cli.js devbox create --architecture arm64 --resources SMALL --entrypoint "sleep 30" --output json'); - // Extract devbox ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - devboxId = match[1]; - expect(devboxId).toBeDefined(); - // Wait for devbox to be ready - await waitForDevboxReady(devboxId); - }, 300000); // 5 minutes timeout - it('should get devbox details', async () => { - expect(devboxId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js devbox get ${devboxId} --output json`); - expect(stdout).toContain('"id":'); - expect(stdout).toContain(devboxId); - }, 30000); - it('should list devboxes', async () => { - const { stdout } = await execAsync('node dist/cli.js devbox list --output json'); - expect(stdout).toContain('"id":'); - }, 30000); - it('should execute command on devbox', async () => { - expect(devboxId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js devbox exec ${devboxId} "echo hello" --output json`); - expect(stdout).toContain('"result":'); - }, 60000); - it('should suspend devbox', async () => { - expect(devboxId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js devbox suspend ${devboxId} --output json`); - expect(stdout).toContain('"id":'); - // Wait for suspend to complete - await new Promise(resolve => setTimeout(resolve, 10000)); - }, 60000); - it('should resume devbox', async () => { - expect(devboxId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js devbox resume ${devboxId} --output json`); - expect(stdout).toContain('"id":'); - }, 60000); - it('should shutdown devbox', async () => { - expect(devboxId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js devbox shutdown ${devboxId} --output json`); - expect(stdout).toContain('"id":'); - }, 30000); - }); - describe('Devbox File Operations', () => { - let devboxId; - beforeAll(async () => { - if (!apiKey) - return; - // Create a devbox for file operations - const { stdout } = await execAsync('node dist/cli.js devbox create --architecture arm64 --resources SMALL --entrypoint "sleep 60" --output json'); - const match = stdout.match(/"id":\s*"([^"]+)"/); - if (match) { - devboxId = match[1]; - // Wait for devbox to be ready - await waitForDevboxReady(devboxId); - } - }, 60000); - afterAll(async () => { - if (devboxId) { - try { - await execAsync(`node dist/cli.js devbox shutdown ${devboxId}`); - } - catch (error) { - console.warn('Failed to cleanup devbox:', error); - } - } - }); - it('should read file from devbox', async () => { - expect(devboxId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js devbox read ${devboxId} --remote /etc/hostname --output-path /tmp/hostname.txt --output json`); - expect(stdout).toContain('"remotePath":'); - }, 30000); - it('should write file to devbox', async () => { - expect(devboxId).toBeDefined(); - // Create a temporary file - const fs = require('fs'); - const path = require('path'); - const tempFile = path.join(require('os').tmpdir(), 'test-file.txt'); - fs.writeFileSync(tempFile, 'Hello, World!'); - try { - const { stdout } = await execAsync(`node dist/cli.js devbox write ${devboxId} --input ${tempFile} --remote /tmp/test-file.txt --output json`); - expect(stdout).toContain('"inputPath":'); - } - finally { - fs.unlinkSync(tempFile); - } - }, 30000); - }); - describe('Devbox Async Operations', () => { - let devboxId; - let executionId; - beforeAll(async () => { - if (!apiKey) - return; - // Create a devbox for async operations - const { stdout } = await execAsync('node dist/cli.js devbox create --architecture arm64 --resources SMALL --entrypoint "sleep 60" --output json'); - const match = stdout.match(/"id":\s*"([^"]+)"/); - if (match) { - devboxId = match[1]; - // Wait for devbox to be ready - await waitForDevboxReady(devboxId); - } - }, 60000); - afterAll(async () => { - if (devboxId) { - try { - await execAsync(`node dist/cli.js devbox shutdown ${devboxId}`); - } - catch (error) { - console.warn('Failed to cleanup devbox:', error); - } - } - }); - it('should execute command asynchronously', async () => { - expect(devboxId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js devbox exec-async ${devboxId} "echo hello" --output json`); - expect(stdout).toContain('"execution_id":'); - // Extract execution ID - const match = stdout.match(/"execution_id":\s*"([^"]+)"/); - if (match) { - executionId = match[1]; - } - }, 30000); - it('should get async execution status', async () => { - expect(devboxId).toBeDefined(); - expect(executionId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js devbox get-async ${devboxId} ${executionId} --output json`); - expect(stdout).toContain('"execution_id":'); - }, 30000); - }); - describe('Devbox Logs', () => { - let devboxId; - beforeAll(async () => { - if (!apiKey) - return; - // Create a devbox for logs - const { stdout } = await execAsync('node dist/cli.js devbox create --architecture arm64 --resources SMALL --entrypoint "echo test && sleep 30" --output json'); - const match = stdout.match(/"id":\s*"([^"]+)"/); - if (match) { - devboxId = match[1]; - // Wait for devbox to be ready - await waitForDevboxReady(devboxId); - } - }, 60000); - afterAll(async () => { - if (devboxId) { - try { - await execAsync(`node dist/cli.js devbox shutdown ${devboxId}`); - } - catch (error) { - console.warn('Failed to cleanup devbox:', error); - } - } - }); - it('should retrieve devbox logs', async () => { - expect(devboxId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js devbox logs ${devboxId} --output json`); - expect(stdout).toContain('"logs":'); - }, 30000); - }); -}); diff --git a/tests/__tests__/integration/devbox.e2e.test.ts b/tests/__tests__/integration/devbox.e2e.test.ts deleted file mode 100644 index 61689448..00000000 --- a/tests/__tests__/integration/devbox.e2e.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { jest } from '@jest/globals'; -import { exec } from 'child_process'; -import { promisify } from 'util'; - -const execAsync = promisify(exec); - -// Helper function to wait for devbox to be ready -async function waitForDevboxReady(devboxId: string, maxWaitTime = 300000): Promise { - const startTime = Date.now(); - const pollInterval = 10000; // 10 seconds - - while (Date.now() - startTime < maxWaitTime) { - try { - const { stdout } = await execAsync(`node dist/cli.js devbox get ${devboxId} --output json`); - const devbox = JSON.parse(stdout); - - if (devbox.status === 'running') { - console.log(`Devbox ${devboxId} is ready!`); - return; - } - - console.log(`Devbox ${devboxId} status: ${devbox.status}, waiting...`); - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } catch (error) { - console.log(`Error checking devbox status: ${error}`); - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - } - - throw new Error(`Devbox ${devboxId} did not become ready within ${maxWaitTime}ms`); -} - -describe('Devbox E2E Tests', () => { - const apiKey = process.env.RUNLOOP_API_KEY; - - beforeAll(() => { - if (!apiKey) { - console.log('Skipping E2E tests: RUNLOOP_API_KEY not set'); - } - }); - - beforeEach(() => { - if (!apiKey) { - pending('RUNLOOP_API_KEY required for E2E tests'); - } - }); - - describe('Devbox Lifecycle', () => { - let devboxId: string; - - it('should create a devbox', async () => { - const { stdout } = await execAsync('node dist/cli.js devbox create --architecture arm64 --resources SMALL --entrypoint "sleep 30" --output json'); - - // Extract devbox ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - devboxId = match![1]; - expect(devboxId).toBeDefined(); - - // Wait for devbox to be ready - await waitForDevboxReady(devboxId); - }, 300000); // 5 minutes timeout - - it('should get devbox details', async () => { - expect(devboxId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js devbox get ${devboxId} --output json`); - - expect(stdout).toContain('"id":'); - expect(stdout).toContain(devboxId); - }, 30000); - - it('should list devboxes', async () => { - const { stdout } = await execAsync('node dist/cli.js devbox list --output json'); - - expect(stdout).toContain('"id":'); - }, 30000); - - it('should execute command on devbox', async () => { - expect(devboxId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js devbox exec ${devboxId} "echo hello" --output json`); - - expect(stdout).toContain('"result":'); - }, 60000); - - it('should suspend devbox', async () => { - expect(devboxId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js devbox suspend ${devboxId} --output json`); - - expect(stdout).toContain('"id":'); - - // Wait for suspend to complete - await new Promise(resolve => setTimeout(resolve, 10000)); - }, 60000); - - it('should resume devbox', async () => { - expect(devboxId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js devbox resume ${devboxId} --output json`); - - expect(stdout).toContain('"id":'); - }, 60000); - - it('should shutdown devbox', async () => { - expect(devboxId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js devbox shutdown ${devboxId} --output json`); - - expect(stdout).toContain('"id":'); - }, 30000); - }); - - describe('Devbox File Operations', () => { - let devboxId: string; - - beforeAll(async () => { - if (!apiKey) return; - - // Create a devbox for file operations - const { stdout } = await execAsync('node dist/cli.js devbox create --architecture arm64 --resources SMALL --entrypoint "sleep 60" --output json'); - const match = stdout.match(/"id":\s*"([^"]+)"/); - if (match) { - devboxId = match[1]; - // Wait for devbox to be ready - await waitForDevboxReady(devboxId); - } - }, 60000); - - afterAll(async () => { - if (devboxId) { - try { - await execAsync(`node dist/cli.js devbox shutdown ${devboxId}`); - } catch (error) { - console.warn('Failed to cleanup devbox:', error); - } - } - }); - - it('should read file from devbox', async () => { - expect(devboxId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js devbox read ${devboxId} --remote /etc/hostname --output-path /tmp/hostname.txt --output json`); - - expect(stdout).toContain('"remotePath":'); - }, 30000); - - it('should write file to devbox', async () => { - expect(devboxId).toBeDefined(); - - // Create a temporary file - const fs = require('fs'); - const path = require('path'); - const tempFile = path.join(require('os').tmpdir(), 'test-file.txt'); - fs.writeFileSync(tempFile, 'Hello, World!'); - - try { - const { stdout } = await execAsync(`node dist/cli.js devbox write ${devboxId} --input ${tempFile} --remote /tmp/test-file.txt --output json`); - - expect(stdout).toContain('"inputPath":'); - } finally { - fs.unlinkSync(tempFile); - } - }, 30000); - }); - - describe('Devbox Async Operations', () => { - let devboxId: string; - let executionId: string; - - beforeAll(async () => { - if (!apiKey) return; - - // Create a devbox for async operations - const { stdout } = await execAsync('node dist/cli.js devbox create --architecture arm64 --resources SMALL --entrypoint "sleep 60" --output json'); - const match = stdout.match(/"id":\s*"([^"]+)"/); - if (match) { - devboxId = match[1]; - // Wait for devbox to be ready - await waitForDevboxReady(devboxId); - } - }, 60000); - - afterAll(async () => { - if (devboxId) { - try { - await execAsync(`node dist/cli.js devbox shutdown ${devboxId}`); - } catch (error) { - console.warn('Failed to cleanup devbox:', error); - } - } - }); - - it('should execute command asynchronously', async () => { - expect(devboxId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js devbox exec-async ${devboxId} "echo hello" --output json`); - - expect(stdout).toContain('"execution_id":'); - - // Extract execution ID - const match = stdout.match(/"execution_id":\s*"([^"]+)"/); - if (match) { - executionId = match[1]; - } - }, 30000); - - it('should get async execution status', async () => { - expect(devboxId).toBeDefined(); - expect(executionId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js devbox get-async ${devboxId} ${executionId} --output json`); - - expect(stdout).toContain('"execution_id":'); - }, 30000); - }); - - describe('Devbox Logs', () => { - let devboxId: string; - - beforeAll(async () => { - if (!apiKey) return; - - // Create a devbox for logs - const { stdout } = await execAsync('node dist/cli.js devbox create --architecture arm64 --resources SMALL --entrypoint "echo test && sleep 30" --output json'); - const match = stdout.match(/"id":\s*"([^"]+)"/); - if (match) { - devboxId = match[1]; - // Wait for devbox to be ready - await waitForDevboxReady(devboxId); - } - }, 60000); - - afterAll(async () => { - if (devboxId) { - try { - await execAsync(`node dist/cli.js devbox shutdown ${devboxId}`); - } catch (error) { - console.warn('Failed to cleanup devbox:', error); - } - } - }); - - it('should retrieve devbox logs', async () => { - expect(devboxId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js devbox logs ${devboxId} --output json`); - - expect(stdout).toContain('"logs":'); - }, 30000); - }); -}); diff --git a/tests/__tests__/integration/object.e2e.test.js b/tests/__tests__/integration/object.e2e.test.js deleted file mode 100644 index d822836f..00000000 --- a/tests/__tests__/integration/object.e2e.test.js +++ /dev/null @@ -1,229 +0,0 @@ -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { writeFileSync, unlinkSync, existsSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -const execAsync = promisify(exec); -describe('Object E2E Tests', () => { - const apiKey = process.env.RUNLOOP_API_KEY; - beforeAll(() => { - if (!apiKey) { - console.log('Skipping E2E tests: RUNLOOP_API_KEY not set'); - } - }); - beforeEach(() => { - if (!apiKey) { - pending('RUNLOOP_API_KEY required for E2E tests'); - } - }); - describe('Object Lifecycle', () => { - let objectId; - let tempFilePath; - let downloadPath; - beforeAll(() => { - // Create a temporary file for upload - tempFilePath = join(tmpdir(), `test-upload-${Date.now()}.txt`); - writeFileSync(tempFilePath, 'Hello, World! This is a test file.'); - downloadPath = join(tmpdir(), `test-download-${Date.now()}.txt`); - }); - afterAll(() => { - // Cleanup temporary files - try { - if (existsSync(tempFilePath)) { - unlinkSync(tempFilePath); - } - if (existsSync(downloadPath)) { - unlinkSync(downloadPath); - } - } - catch (error) { - console.warn('Failed to cleanup temp files:', error); - } - }); - it('should upload file as object', async () => { - const { stdout } = await execAsync(`node dist/cli.js object upload ${tempFilePath} --name test-object --output json`); - // Extract object ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - objectId = match[1]; - expect(objectId).toBeDefined(); - }, 60000); - it('should list objects', async () => { - const { stdout } = await execAsync('node dist/cli.js object list'); - expect(stdout).toContain('obj_'); - }, 30000); - it('should get object details', async () => { - expect(objectId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js object get ${objectId}`); - expect(stdout).toContain('obj_'); - expect(stdout).toContain(objectId); - }, 30000); - it('should download object', async () => { - expect(objectId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js object download ${objectId} ${downloadPath}`); - expect(stdout).toContain('objectId'); - expect(existsSync(downloadPath)).toBe(true); - }, 30000); - it('should delete object', async () => { - expect(objectId).toBeDefined(); - const { stdout } = await execAsync(`node dist/cli.js object delete ${objectId}`); - expect(stdout).toContain('obj_'); - }, 30000); - }); - describe('Object Filtering', () => { - let objectIds = []; - beforeAll(async () => { - if (!apiKey) - return; - // Create multiple test objects - const tempFiles = []; - for (let i = 0; i < 3; i++) { - const tempFile = join(tmpdir(), `test-filter-${i}-${Date.now()}.txt`); - writeFileSync(tempFile, `Test content ${i}`); - tempFiles.push(tempFile); - } - try { - // Upload objects - for (let i = 0; i < tempFiles.length; i++) { - const { stdout } = await execAsync(`node dist/cli.js object upload ${tempFiles[i]} --name test-filter-${i}`); - const match = stdout.match(/"id":\s*"([^"]+)"/); - if (match) { - objectIds.push(match[1]); - } - } - } - finally { - // Cleanup temp files - tempFiles.forEach(file => { - try { - if (existsSync(file)) { - unlinkSync(file); - } - } - catch (error) { - console.warn('Failed to cleanup temp file:', error); - } - }); - } - }, 120000); - afterAll(async () => { - // Cleanup uploaded objects - for (const objectId of objectIds) { - try { - await execAsync(`node dist/cli.js object delete ${objectId}`); - } - catch (error) { - console.warn('Failed to cleanup object:', error); - } - } - }, 60000); - it('should filter objects by name', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --name test-filter-0'); - expect(stdout).toContain('obj_'); - }, 30000); - it('should filter objects by content type', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --content-type text/plain'); - expect(stdout).toContain('obj_'); - }, 30000); - it('should filter objects by state', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --state READ_ONLY'); - expect(stdout).toContain('obj_'); - }, 30000); - it('should search objects', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --search test-filter'); - expect(stdout).toContain('obj_'); - }, 30000); - it('should limit results', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --limit 2'); - expect(stdout).toContain('obj_'); - }, 30000); - }); - describe('Object Content Types', () => { - let objectIds = []; - beforeAll(async () => { - if (!apiKey) - return; - // Create files with different extensions - const testFiles = [ - { path: join(tmpdir(), `test-${Date.now()}.txt`), content: 'Text file' }, - { path: join(tmpdir(), `test-${Date.now()}.json`), content: '{"test": "data"}' }, - { path: join(tmpdir(), `test-${Date.now()}.html`), content: 'Test' } - ]; - try { - // Create and upload files - for (const file of testFiles) { - writeFileSync(file.path, file.content); - const { stdout } = await execAsync(`node dist/cli.js object upload ${file.path} --name test-${file.name}`); - const match = stdout.match(/"id":\s*"([^"]+)"/); - if (match) { - objectIds.push(match[1]); - } - } - } - finally { - // Cleanup temp files - testFiles.forEach(file => { - try { - if (existsSync(file.path)) { - unlinkSync(file.path); - } - } - catch (error) { - console.warn('Failed to cleanup temp file:', error); - } - }); - } - }, 120000); - afterAll(async () => { - // Cleanup uploaded objects - for (const objectId of objectIds) { - try { - await execAsync(`node dist/cli.js object delete ${objectId}`); - } - catch (error) { - console.warn('Failed to cleanup object:', error); - } - } - }, 60000); - it('should auto-detect content types', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --output json'); - expect(stdout).toContain('obj_'); - // Should contain different content types - expect(stdout).toMatch(/\"content_type\":\s*\"text\"/); - }, 30000); - }); - describe('Object Public Access', () => { - let objectId; - let tempFilePath; - beforeAll(() => { - tempFilePath = join(tmpdir(), `test-public-${Date.now()}.txt`); - writeFileSync(tempFilePath, 'Public test content'); - }); - afterAll(async () => { - // Cleanup - try { - if (existsSync(tempFilePath)) { - unlinkSync(tempFilePath); - } - if (objectId) { - await execAsync(`node dist/cli.js object delete ${objectId}`); - } - } - catch (error) { - console.warn('Failed to cleanup:', error); - } - }); - it('should upload public object', async () => { - const { stdout } = await execAsync(`node dist/cli.js object upload ${tempFilePath} --name test-public --public --output json`); - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - objectId = match[1]; - expect(objectId).toBeDefined(); - }, 60000); - it('should filter public objects', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --public true --output json'); - // Should return valid JSON (empty array if no public objects) - expect(stdout.trim()).toMatch(/^\[.*\]$/); - }, 30000); - }); -}); diff --git a/tests/__tests__/integration/object.e2e.test.ts b/tests/__tests__/integration/object.e2e.test.ts deleted file mode 100644 index a0986cea..00000000 --- a/tests/__tests__/integration/object.e2e.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { jest } from '@jest/globals'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { writeFileSync, unlinkSync, existsSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; - -const execAsync = promisify(exec); - -describe('Object E2E Tests', () => { - const apiKey = process.env.RUNLOOP_API_KEY; - - beforeAll(() => { - if (!apiKey) { - console.log('Skipping E2E tests: RUNLOOP_API_KEY not set'); - } - }); - - beforeEach(() => { - if (!apiKey) { - pending('RUNLOOP_API_KEY required for E2E tests'); - } - }); - - describe('Object Lifecycle', () => { - let objectId: string; - let tempFilePath: string; - let downloadPath: string; - - beforeAll(() => { - // Create a temporary file for upload - tempFilePath = join(tmpdir(), `test-upload-${Date.now()}.txt`); - writeFileSync(tempFilePath, 'Hello, World! This is a test file.'); - - downloadPath = join(tmpdir(), `test-download-${Date.now()}.txt`); - }); - - afterAll(() => { - // Cleanup temporary files - try { - if (existsSync(tempFilePath)) { - unlinkSync(tempFilePath); - } - if (existsSync(downloadPath)) { - unlinkSync(downloadPath); - } - } catch (error) { - console.warn('Failed to cleanup temp files:', error); - } - }); - - it('should upload file as object', async () => { - const { stdout } = await execAsync(`node dist/cli.js object upload ${tempFilePath} --name test-object --output json`); - - // Extract object ID from output - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - objectId = match![1]; - expect(objectId).toBeDefined(); - }, 60000); - - it('should list objects', async () => { - const { stdout } = await execAsync('node dist/cli.js object list'); - - expect(stdout).toContain('obj_'); - }, 30000); - - it('should get object details', async () => { - expect(objectId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js object get ${objectId}`); - - expect(stdout).toContain('obj_'); - expect(stdout).toContain(objectId); - }, 30000); - - it('should download object', async () => { - expect(objectId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js object download ${objectId} ${downloadPath}`); - - expect(stdout).toContain('objectId'); - expect(existsSync(downloadPath)).toBe(true); - }, 30000); - - it('should delete object', async () => { - expect(objectId).toBeDefined(); - - const { stdout } = await execAsync(`node dist/cli.js object delete ${objectId}`); - - expect(stdout).toContain('obj_'); - }, 30000); - }); - - describe('Object Filtering', () => { - let objectIds: string[] = []; - - beforeAll(async () => { - if (!apiKey) return; - - // Create multiple test objects - const tempFiles = []; - for (let i = 0; i < 3; i++) { - const tempFile = join(tmpdir(), `test-filter-${i}-${Date.now()}.txt`); - writeFileSync(tempFile, `Test content ${i}`); - tempFiles.push(tempFile); - } - - try { - // Upload objects - for (let i = 0; i < tempFiles.length; i++) { - const { stdout } = await execAsync(`node dist/cli.js object upload ${tempFiles[i]} --name test-filter-${i}`); - const match = stdout.match(/"id":\s*"([^"]+)"/); - if (match) { - objectIds.push(match[1]); - } - } - } finally { - // Cleanup temp files - tempFiles.forEach(file => { - try { - if (existsSync(file)) { - unlinkSync(file); - } - } catch (error) { - console.warn('Failed to cleanup temp file:', error); - } - }); - } - }, 120000); - - afterAll(async () => { - // Cleanup uploaded objects - for (const objectId of objectIds) { - try { - await execAsync(`node dist/cli.js object delete ${objectId}`); - } catch (error) { - console.warn('Failed to cleanup object:', error); - } - } - }, 60000); - - it('should filter objects by name', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --name test-filter-0'); - - expect(stdout).toContain('obj_'); - }, 30000); - - it('should filter objects by content type', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --content-type text/plain'); - - expect(stdout).toContain('obj_'); - }, 30000); - - it('should filter objects by state', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --state READ_ONLY'); - - expect(stdout).toContain('obj_'); - }, 30000); - - it('should search objects', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --search test-filter'); - - expect(stdout).toContain('obj_'); - }, 30000); - - it('should limit results', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --limit 2'); - - expect(stdout).toContain('obj_'); - }, 30000); - }); - - describe('Object Content Types', () => { - let objectIds: string[] = []; - - beforeAll(async () => { - if (!apiKey) return; - - // Create files with different extensions - const testFiles = [ - { path: join(tmpdir(), `test-${Date.now()}.txt`), content: 'Text file' }, - { path: join(tmpdir(), `test-${Date.now()}.json`), content: '{"test": "data"}' }, - { path: join(tmpdir(), `test-${Date.now()}.html`), content: 'Test' } - ]; - - try { - // Create and upload files - for (const file of testFiles) { - writeFileSync(file.path, file.content); - const { stdout } = await execAsync(`node dist/cli.js object upload ${file.path} --name test-${file.name}`); - const match = stdout.match(/"id":\s*"([^"]+)"/); - if (match) { - objectIds.push(match[1]); - } - } - } finally { - // Cleanup temp files - testFiles.forEach(file => { - try { - if (existsSync(file.path)) { - unlinkSync(file.path); - } - } catch (error) { - console.warn('Failed to cleanup temp file:', error); - } - }); - } - }, 120000); - - afterAll(async () => { - // Cleanup uploaded objects - for (const objectId of objectIds) { - try { - await execAsync(`node dist/cli.js object delete ${objectId}`); - } catch (error) { - console.warn('Failed to cleanup object:', error); - } - } - }, 60000); - - it('should auto-detect content types', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --output json'); - - expect(stdout).toContain('obj_'); - // Should contain different content types - expect(stdout).toMatch(/\"content_type\":\s*\"text\"/); - }, 30000); - }); - - describe('Object Public Access', () => { - let objectId: string; - let tempFilePath: string; - - beforeAll(() => { - tempFilePath = join(tmpdir(), `test-public-${Date.now()}.txt`); - writeFileSync(tempFilePath, 'Public test content'); - }); - - afterAll(async () => { - // Cleanup - try { - if (existsSync(tempFilePath)) { - unlinkSync(tempFilePath); - } - if (objectId) { - await execAsync(`node dist/cli.js object delete ${objectId}`); - } - } catch (error) { - console.warn('Failed to cleanup:', error); - } - }); - - it('should upload public object', async () => { - const { stdout } = await execAsync(`node dist/cli.js object upload ${tempFilePath} --name test-public --public --output json`); - - const match = stdout.match(/"id":\s*"([^"]+)"/); - expect(match).toBeTruthy(); - objectId = match![1]; - expect(objectId).toBeDefined(); - }, 60000); - - it('should filter public objects', async () => { - const { stdout } = await execAsync('node dist/cli.js object list --public true --output json'); - - // Should return valid JSON (empty array if no public objects) - expect(stdout.trim()).toMatch(/^\[.*\]$/); - }, 30000); - }); -}); diff --git a/tests/__tests__/unit/command-executor.test.js b/tests/__tests__/unit/command-executor.test.js deleted file mode 100644 index f20e0cfd..00000000 --- a/tests/__tests__/unit/command-executor.test.js +++ /dev/null @@ -1,208 +0,0 @@ -import { CommandExecutor } from "@/utils/CommandExecutor"; -// Mock the output functions -jest.mock("@/utils/output", () => ({ - shouldUseNonInteractiveOutput: jest.fn(), - outputList: jest.fn(), - outputResult: jest.fn(), -})); -// Mock the client -jest.mock("@/utils/client", () => ({ - getClient: jest.fn(() => ({ - devboxes: { - list: jest.fn(), - create: jest.fn(), - delete: jest.fn(), - }, - })), -})); -// Mock ink render -jest.mock("ink", () => ({ - render: jest.fn(() => ({ - waitUntilExit: jest.fn(() => Promise.resolve()), - })), -})); -import { shouldUseNonInteractiveOutput, outputList, outputResult } from "@/utils/output"; -const mockShouldUseNonInteractiveOutput = shouldUseNonInteractiveOutput; -const mockOutputList = outputList; -const mockOutputResult = outputResult; -describe("CommandExecutor", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - describe("Constructor", () => { - it("should set default output to json when none specified", () => { - const executor = new CommandExecutor(); - expect(executor["options"].output).toBe("json"); - }); - it("should not override explicit output format", () => { - const options = { output: "yaml" }; - const executor = new CommandExecutor(options); - expect(executor["options"].output).toBe("yaml"); - }); - it("should not override when output is explicitly undefined", () => { - const options = { output: undefined }; - const executor = new CommandExecutor(options); - expect(executor["options"].output).toBe("json"); - }); - }); - describe("executeList", () => { - it("should use non-interactive mode with default JSON output", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - const executor = new CommandExecutor(); - const mockFetchData = jest.fn().mockResolvedValue([{ id: "test" }]); - const mockRenderUI = jest.fn(); - await executor.executeList(mockFetchData, mockRenderUI, 10); - expect(mockShouldUseNonInteractiveOutput).toHaveBeenCalledWith({ output: "json" }); - expect(mockFetchData).toHaveBeenCalled(); - expect(mockOutputList).toHaveBeenCalledWith([{ id: "test" }], { output: "json" }); - }); - it("should use interactive mode when output is undefined", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(false); - const executor = new CommandExecutor({}); - const mockFetchData = jest.fn().mockResolvedValue([{ id: "test" }]); - const mockRenderUI = jest.fn(); - await executor.executeList(mockFetchData, mockRenderUI, 10); - expect(mockShouldUseNonInteractiveOutput).toHaveBeenCalledWith({ output: "json" }); - expect(mockFetchData).not.toHaveBeenCalled(); - expect(mockOutputList).not.toHaveBeenCalled(); - }); - it("should limit results in non-interactive mode", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - const executor = new CommandExecutor(); - const mockFetchData = jest.fn().mockResolvedValue([ - { id: "test1" }, - { id: "test2" }, - { id: "test3" }, - { id: "test4" }, - { id: "test5" } - ]); - const mockRenderUI = jest.fn(); - await executor.executeList(mockFetchData, mockRenderUI, 3); - expect(mockOutputList).toHaveBeenCalledWith([ - { id: "test1" }, - { id: "test2" }, - { id: "test3" } - ], { output: "json" }); - }); - it("should handle errors in non-interactive mode", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - const executor = new CommandExecutor(); - const mockFetchData = jest.fn().mockRejectedValue(new Error("Test error")); - const mockRenderUI = jest.fn(); - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); - const processSpy = jest.spyOn(process, "exit").mockImplementation(); - await executor.executeList(mockFetchData, mockRenderUI, 10); - expect(consoleSpy).toHaveBeenCalled(); - expect(processSpy).toHaveBeenCalledWith(1); - consoleSpy.mockRestore(); - processSpy.mockRestore(); - }); - }); - describe("executeAction", () => { - it("should use non-interactive mode with default JSON output", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - const executor = new CommandExecutor(); - const mockPerformAction = jest.fn().mockResolvedValue({ id: "test", status: "success" }); - const mockRenderUI = jest.fn(); - await executor.executeAction(mockPerformAction, mockRenderUI); - expect(mockShouldUseNonInteractiveOutput).toHaveBeenCalledWith({ output: "json" }); - expect(mockPerformAction).toHaveBeenCalled(); - expect(mockOutputResult).toHaveBeenCalledWith({ id: "test", status: "success" }, { output: "json" }); - }); - it("should use interactive mode when output is undefined", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(false); - const executor = new CommandExecutor({}); - const mockPerformAction = jest.fn().mockResolvedValue({ id: "test", status: "success" }); - const mockRenderUI = jest.fn(); - await executor.executeAction(mockPerformAction, mockRenderUI); - expect(mockShouldUseNonInteractiveOutput).toHaveBeenCalledWith({ output: "json" }); - expect(mockPerformAction).not.toHaveBeenCalled(); - expect(mockOutputResult).not.toHaveBeenCalled(); - }); - it("should handle errors in non-interactive mode", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - const executor = new CommandExecutor(); - const mockPerformAction = jest.fn().mockRejectedValue(new Error("Test error")); - const mockRenderUI = jest.fn(); - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); - const processSpy = jest.spyOn(process, "exit").mockImplementation(); - await executor.executeAction(mockPerformAction, mockRenderUI); - expect(consoleSpy).toHaveBeenCalled(); - expect(processSpy).toHaveBeenCalledWith(1); - consoleSpy.mockRestore(); - processSpy.mockRestore(); - }); - }); - describe("executeDelete", () => { - it("should use non-interactive mode with default JSON output", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - const executor = new CommandExecutor(); - const mockPerformDelete = jest.fn().mockResolvedValue(undefined); - const mockRenderUI = jest.fn(); - await executor.executeDelete(mockPerformDelete, "test-id", mockRenderUI); - expect(mockShouldUseNonInteractiveOutput).toHaveBeenCalledWith({ output: "json" }); - expect(mockPerformDelete).toHaveBeenCalled(); - expect(mockOutputResult).toHaveBeenCalledWith({ id: "test-id", status: "deleted" }, { output: "json" }); - }); - it("should use interactive mode when output is undefined", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(false); - const executor = new CommandExecutor({}); - const mockPerformDelete = jest.fn().mockResolvedValue(undefined); - const mockRenderUI = jest.fn(); - await executor.executeDelete(mockPerformDelete, "test-id", mockRenderUI); - expect(mockShouldUseNonInteractiveOutput).toHaveBeenCalledWith({ output: "json" }); - expect(mockPerformDelete).not.toHaveBeenCalled(); - expect(mockOutputResult).not.toHaveBeenCalled(); - }); - it("should handle errors in non-interactive mode", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - const executor = new CommandExecutor(); - const mockPerformDelete = jest.fn().mockRejectedValue(new Error("Test error")); - const mockRenderUI = jest.fn(); - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); - const processSpy = jest.spyOn(process, "exit").mockImplementation(); - await executor.executeDelete(mockPerformDelete, "test-id", mockRenderUI); - expect(consoleSpy).toHaveBeenCalled(); - expect(processSpy).toHaveBeenCalledWith(1); - consoleSpy.mockRestore(); - processSpy.mockRestore(); - }); - }); - describe("fetchFromIterator", () => { - it("should fetch items from iterator with limit", async () => { - const executor = new CommandExecutor(); - const mockIterator = async function* () { - yield { id: "item1" }; - yield { id: "item2" }; - yield { id: "item3" }; - yield { id: "item4" }; - yield { id: "item5" }; - }; - const result = await executor.fetchFromIterator(mockIterator(), { limit: 3 }); - expect(result).toHaveLength(3); - expect(result).toEqual([ - { id: "item1" }, - { id: "item2" }, - { id: "item3" } - ]); - }); - it("should apply filter when provided", async () => { - const executor = new CommandExecutor(); - const mockIterator = async function* () { - yield { id: "item1", status: "active" }; - yield { id: "item2", status: "inactive" }; - yield { id: "item3", status: "active" }; - yield { id: "item4", status: "inactive" }; - }; - const result = await executor.fetchFromIterator(mockIterator(), { - limit: 10, - filter: (item) => item.status === "active" - }); - expect(result).toHaveLength(2); - expect(result).toEqual([ - { id: "item1", status: "active" }, - { id: "item3", status: "active" } - ]); - }); - }); -}); diff --git a/tests/__tests__/unit/command-executor.test.ts b/tests/__tests__/unit/command-executor.test.ts deleted file mode 100644 index a34dc341..00000000 --- a/tests/__tests__/unit/command-executor.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { CommandExecutor } from "@/utils/CommandExecutor"; -import { OutputOptions } from "@/utils/output"; - -// Mock the output functions -jest.mock("@/utils/output", () => ({ - shouldUseNonInteractiveOutput: jest.fn(), - outputList: jest.fn(), - outputResult: jest.fn(), -})); - -// Mock the client -jest.mock("@/utils/client", () => ({ - getClient: jest.fn(() => ({ - devboxes: { - list: jest.fn(), - create: jest.fn(), - delete: jest.fn(), - }, - })), -})); - -// Mock ink render -jest.mock("ink", () => ({ - render: jest.fn(() => ({ - waitUntilExit: jest.fn(() => Promise.resolve()), - })), -})); - -import { shouldUseNonInteractiveOutput, outputList, outputResult } from "@/utils/output"; - -const mockShouldUseNonInteractiveOutput = shouldUseNonInteractiveOutput as jest.MockedFunction; -const mockOutputList = outputList as jest.MockedFunction; -const mockOutputResult = outputResult as jest.MockedFunction; - -describe("CommandExecutor", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe("Constructor", () => { - it("should set default output to json when none specified", () => { - const executor = new CommandExecutor(); - expect(executor["options"].output).toBe("json"); - }); - - it("should not override explicit output format", () => { - const options: OutputOptions = { output: "yaml" }; - const executor = new CommandExecutor(options); - expect(executor["options"].output).toBe("yaml"); - }); - - it("should not override when output is explicitly undefined", () => { - const options: OutputOptions = { output: undefined }; - const executor = new CommandExecutor(options); - expect(executor["options"].output).toBe("json"); - }); - }); - - describe("executeList", () => { - it("should use non-interactive mode with default JSON output", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - - const executor = new CommandExecutor(); - const mockFetchData = jest.fn().mockResolvedValue([{ id: "test" }]); - const mockRenderUI = jest.fn(); - - await executor.executeList(mockFetchData, mockRenderUI, 10); - - expect(mockShouldUseNonInteractiveOutput).toHaveBeenCalledWith({ output: "json" }); - expect(mockFetchData).toHaveBeenCalled(); - expect(mockOutputList).toHaveBeenCalledWith([{ id: "test" }], { output: "json" }); - }); - - it("should use interactive mode when output is undefined", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(false); - - const executor = new CommandExecutor({}); - const mockFetchData = jest.fn().mockResolvedValue([{ id: "test" }]); - const mockRenderUI = jest.fn(); - - await executor.executeList(mockFetchData, mockRenderUI, 10); - - expect(mockShouldUseNonInteractiveOutput).toHaveBeenCalledWith({ output: "json" }); - expect(mockFetchData).not.toHaveBeenCalled(); - expect(mockOutputList).not.toHaveBeenCalled(); - }); - - it("should limit results in non-interactive mode", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - - const executor = new CommandExecutor(); - const mockFetchData = jest.fn().mockResolvedValue([ - { id: "test1" }, - { id: "test2" }, - { id: "test3" }, - { id: "test4" }, - { id: "test5" } - ]); - const mockRenderUI = jest.fn(); - - await executor.executeList(mockFetchData, mockRenderUI, 3); - - expect(mockOutputList).toHaveBeenCalledWith([ - { id: "test1" }, - { id: "test2" }, - { id: "test3" } - ], { output: "json" }); - }); - - it("should handle errors in non-interactive mode", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - - const executor = new CommandExecutor(); - const mockFetchData = jest.fn().mockRejectedValue(new Error("Test error")); - const mockRenderUI = jest.fn(); - - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); - const processSpy = jest.spyOn(process, "exit").mockImplementation(); - - await executor.executeList(mockFetchData, mockRenderUI, 10); - - expect(consoleSpy).toHaveBeenCalled(); - expect(processSpy).toHaveBeenCalledWith(1); - - consoleSpy.mockRestore(); - processSpy.mockRestore(); - }); - }); - - describe("executeAction", () => { - it("should use non-interactive mode with default JSON output", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - - const executor = new CommandExecutor(); - const mockPerformAction = jest.fn().mockResolvedValue({ id: "test", status: "success" }); - const mockRenderUI = jest.fn(); - - await executor.executeAction(mockPerformAction, mockRenderUI); - - expect(mockShouldUseNonInteractiveOutput).toHaveBeenCalledWith({ output: "json" }); - expect(mockPerformAction).toHaveBeenCalled(); - expect(mockOutputResult).toHaveBeenCalledWith( - { id: "test", status: "success" }, - { output: "json" } - ); - }); - - it("should use interactive mode when output is undefined", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(false); - - const executor = new CommandExecutor({}); - const mockPerformAction = jest.fn().mockResolvedValue({ id: "test", status: "success" }); - const mockRenderUI = jest.fn(); - - await executor.executeAction(mockPerformAction, mockRenderUI); - - expect(mockShouldUseNonInteractiveOutput).toHaveBeenCalledWith({ output: "json" }); - expect(mockPerformAction).not.toHaveBeenCalled(); - expect(mockOutputResult).not.toHaveBeenCalled(); - }); - - it("should handle errors in non-interactive mode", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - - const executor = new CommandExecutor(); - const mockPerformAction = jest.fn().mockRejectedValue(new Error("Test error")); - const mockRenderUI = jest.fn(); - - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); - const processSpy = jest.spyOn(process, "exit").mockImplementation(); - - await executor.executeAction(mockPerformAction, mockRenderUI); - - expect(consoleSpy).toHaveBeenCalled(); - expect(processSpy).toHaveBeenCalledWith(1); - - consoleSpy.mockRestore(); - processSpy.mockRestore(); - }); - }); - - describe("executeDelete", () => { - it("should use non-interactive mode with default JSON output", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - - const executor = new CommandExecutor(); - const mockPerformDelete = jest.fn().mockResolvedValue(undefined); - const mockRenderUI = jest.fn(); - - await executor.executeDelete(mockPerformDelete, "test-id", mockRenderUI); - - expect(mockShouldUseNonInteractiveOutput).toHaveBeenCalledWith({ output: "json" }); - expect(mockPerformDelete).toHaveBeenCalled(); - expect(mockOutputResult).toHaveBeenCalledWith( - { id: "test-id", status: "deleted" }, - { output: "json" } - ); - }); - - it("should use interactive mode when output is undefined", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(false); - - const executor = new CommandExecutor({}); - const mockPerformDelete = jest.fn().mockResolvedValue(undefined); - const mockRenderUI = jest.fn(); - - await executor.executeDelete(mockPerformDelete, "test-id", mockRenderUI); - - expect(mockShouldUseNonInteractiveOutput).toHaveBeenCalledWith({ output: "json" }); - expect(mockPerformDelete).not.toHaveBeenCalled(); - expect(mockOutputResult).not.toHaveBeenCalled(); - }); - - it("should handle errors in non-interactive mode", async () => { - mockShouldUseNonInteractiveOutput.mockReturnValue(true); - - const executor = new CommandExecutor(); - const mockPerformDelete = jest.fn().mockRejectedValue(new Error("Test error")); - const mockRenderUI = jest.fn(); - - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); - const processSpy = jest.spyOn(process, "exit").mockImplementation(); - - await executor.executeDelete(mockPerformDelete, "test-id", mockRenderUI); - - expect(consoleSpy).toHaveBeenCalled(); - expect(processSpy).toHaveBeenCalledWith(1); - - consoleSpy.mockRestore(); - processSpy.mockRestore(); - }); - }); - - describe("fetchFromIterator", () => { - it("should fetch items from iterator with limit", async () => { - const executor = new CommandExecutor(); - - const mockIterator = async function* () { - yield { id: "item1" }; - yield { id: "item2" }; - yield { id: "item3" }; - yield { id: "item4" }; - yield { id: "item5" }; - }; - - const result = await executor.fetchFromIterator(mockIterator(), { limit: 3 }); - - expect(result).toHaveLength(3); - expect(result).toEqual([ - { id: "item1" }, - { id: "item2" }, - { id: "item3" } - ]); - }); - - it("should apply filter when provided", async () => { - const executor = new CommandExecutor(); - - const mockIterator = async function* () { - yield { id: "item1", status: "active" }; - yield { id: "item2", status: "inactive" }; - yield { id: "item3", status: "active" }; - yield { id: "item4", status: "inactive" }; - }; - - const result = await executor.fetchFromIterator( - mockIterator(), - { - limit: 10, - filter: (item: any) => item.status === "active" - } - ); - - expect(result).toHaveLength(2); - expect(result).toEqual([ - { id: "item1", status: "active" }, - { id: "item3", status: "active" } - ]); - }); - }); -}); diff --git a/tests/__tests__/unit/commands/blueprint.test.js b/tests/__tests__/unit/commands/blueprint.test.js deleted file mode 100644 index 48c3b0bc..00000000 --- a/tests/__tests__/unit/commands/blueprint.test.js +++ /dev/null @@ -1,329 +0,0 @@ -import { jest } from '@jest/globals'; -import { mockBlueprint, mockAPIClient } from '../../../fixtures/mocks'; -import { createMockCommandOptions } from '../../../helpers'; -// Mock the client and executor -jest.mock('@/utils/client', () => ({ - getClient: jest.fn() -})); -jest.mock('@/utils/CommandExecutor', () => ({ - createExecutor: jest.fn() -})); -describe('Blueprint Commands', () => { - let mockClient; - let mockExecutor; - beforeEach(() => { - jest.clearAllMocks(); - mockClient = mockAPIClient(); - mockExecutor = { - getClient: jest.fn().mockReturnValue(mockClient), - executeAction: jest.fn() - }; - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - }); - describe('createBlueprint', () => { - const mockFs = { - existsSync: jest.fn(), - readFileSync: jest.fn() - }; - beforeEach(() => { - jest.doMock('fs', () => mockFs); - }); - it('should create blueprint with inline dockerfile', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.create.mockResolvedValue(mockBlueprintData); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - const { createBlueprint } = await import('@/commands/blueprint/create'); - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfile: 'FROM ubuntu:20.04', - systemSetupCommands: ['apt update', 'apt install -y git'], - resources: 'SMALL', - architecture: 'arm64', - availablePorts: [3000, 8080] - }); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - it('should create blueprint with dockerfile file', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.create.mockResolvedValue(mockBlueprintData); - mockFs.existsSync.mockReturnValue(true); - mockFs.readFileSync.mockReturnValue('FROM ubuntu:20.04\nRUN apt update'); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - const { createBlueprint } = await import('@/commands/blueprint/create'); - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfilePath: '/path/to/Dockerfile', - systemSetupCommands: ['apt update'], - resources: 'MEDIUM' - }); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - it('should handle dockerfile file not found', async () => { - mockFs.existsSync.mockReturnValue(false); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - const { createBlueprint } = await import('@/commands/blueprint/create'); - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfilePath: '/nonexistent/Dockerfile' - }); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - it('should create blueprint with user parameters', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.create.mockResolvedValue(mockBlueprintData); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - const { createBlueprint } = await import('@/commands/blueprint/create'); - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfile: 'FROM ubuntu:20.04', - user: 'testuser:1000', - resources: 'LARGE' - }); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - }); - describe('previewBlueprint', () => { - it('should preview blueprint without creating', async () => { - const mockPreview = { - dockerfile: 'FROM ubuntu:20.04', - system_setup_commands: ['apt update'], - launch_parameters: { - resource_size_request: 'SMALL', - architecture: 'arm64' - } - }; - mockClient.blueprints.preview.mockResolvedValue(mockPreview); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - const { previewBlueprint } = await import('@/commands/blueprint/preview'); - await previewBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfile: 'FROM ubuntu:20.04', - systemSetupCommands: ['apt update'], - resources: 'SMALL', - architecture: 'arm64' - }); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - }); - describe('getBlueprint', () => { - it('should retrieve blueprint details', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.retrieve.mockResolvedValue(mockBlueprintData); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - const { getBlueprint } = await import('@/commands/blueprint/get'); - await getBlueprint('bp-test-id', createMockCommandOptions()); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - it('should handle blueprint not found', async () => { - mockClient.blueprints.retrieve.mockRejectedValue(new Error('Blueprint not found')); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - const { getBlueprint } = await import('@/commands/blueprint/get'); - await getBlueprint('nonexistent-id', createMockCommandOptions()); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - }); - describe('logsBlueprint', () => { - it('should retrieve blueprint build logs', async () => { - const mockLogs = { - logs: [ - { - timestamp_ms: 1710000000000, - message: 'Building blueprint...', - level: 'info' - }, - { - timestamp_ms: 1710000001000, - message: 'Build completed successfully', - level: 'info' - } - ] - }; - mockClient.blueprints.logs.mockResolvedValue(mockLogs); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - const { getBlueprintLogs } = await import('@/commands/blueprint/logs'); - await getBlueprintLogs({ id: 'bp-test-id', ...createMockCommandOptions() }); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - it('should handle blueprint logs not found', async () => { - mockClient.blueprints.logs.mockRejectedValue(new Error('Logs not found')); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - const { getBlueprintLogs } = await import('@/commands/blueprint/logs'); - await getBlueprintLogs({ id: 'nonexistent-id', ...createMockCommandOptions() }); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - }); - describe('Blueprint Creation Edge Cases', () => { - it('should handle empty system setup commands', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.create.mockResolvedValue(mockBlueprintData); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - const { createBlueprint } = await import('@/commands/blueprint/create'); - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfile: 'FROM ubuntu:20.04', - systemSetupCommands: [], - resources: 'SMALL' - }); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - it('should handle multiple available ports', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.create.mockResolvedValue(mockBlueprintData); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - const { createBlueprint } = await import('@/commands/blueprint/create'); - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfile: 'FROM ubuntu:20.04', - availablePorts: [3000, 8080, 9000], - resources: 'SMALL' - }); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - it('should handle root user parameter', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.create.mockResolvedValue(mockBlueprintData); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - const { createBlueprint } = await import('@/commands/blueprint/create'); - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfile: 'FROM ubuntu:20.04', - root: true, - resources: 'SMALL' - }); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - }); -}); diff --git a/tests/__tests__/unit/commands/blueprint.test.ts b/tests/__tests__/unit/commands/blueprint.test.ts deleted file mode 100644 index 1c927d9b..00000000 --- a/tests/__tests__/unit/commands/blueprint.test.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { jest } from '@jest/globals'; -import { mockBlueprint, mockAPIClient } from '../../../fixtures/mocks'; -import { createMockCommandOptions } from '../../../helpers'; - -// Mock the client and executor -jest.mock('@/utils/client', () => ({ - getClient: jest.fn() -})); - -jest.mock('@/utils/CommandExecutor', () => ({ - createExecutor: jest.fn() -})); - -describe('Blueprint Commands', () => { - let mockClient: any; - let mockExecutor: any; - - beforeEach(() => { - jest.clearAllMocks(); - - mockClient = mockAPIClient(); - mockExecutor = { - getClient: jest.fn().mockReturnValue(mockClient), - executeAction: jest.fn() - }; - - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - }); - - describe('createBlueprint', () => { - const mockFs = { - existsSync: jest.fn(), - readFileSync: jest.fn() - }; - - beforeEach(() => { - jest.doMock('fs', () => mockFs); - }); - - it('should create blueprint with inline dockerfile', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.create.mockResolvedValue(mockBlueprintData); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - - const { createBlueprint } = await import('@/commands/blueprint/create'); - - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfile: 'FROM ubuntu:20.04', - systemSetupCommands: ['apt update', 'apt install -y git'], - resources: 'SMALL', - architecture: 'arm64', - availablePorts: [3000, 8080] - }); - - // Test passes if no error is thrown - expect(true).toBe(true); - }); - - it('should create blueprint with dockerfile file', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.create.mockResolvedValue(mockBlueprintData); - mockFs.existsSync.mockReturnValue(true); - mockFs.readFileSync.mockReturnValue('FROM ubuntu:20.04\nRUN apt update'); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - - const { createBlueprint } = await import('@/commands/blueprint/create'); - - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfilePath: '/path/to/Dockerfile', - systemSetupCommands: ['apt update'], - resources: 'MEDIUM' - }); - - // Test passes if no error is thrown - expect(true).toBe(true); - }); - - it('should handle dockerfile file not found', async () => { - mockFs.existsSync.mockReturnValue(false); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - - const { createBlueprint } = await import('@/commands/blueprint/create'); - - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfilePath: '/nonexistent/Dockerfile' - }); - - // Test passes if no error is thrown - expect(true).toBe(true); - }); - - it('should create blueprint with user parameters', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.create.mockResolvedValue(mockBlueprintData); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - - const { createBlueprint } = await import('@/commands/blueprint/create'); - - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfile: 'FROM ubuntu:20.04', - user: 'testuser:1000', - resources: 'LARGE' - }); - - // Test passes if no error is thrown - expect(true).toBe(true); - }); - }); - - describe('previewBlueprint', () => { - it('should preview blueprint without creating', async () => { - const mockPreview = { - dockerfile: 'FROM ubuntu:20.04', - system_setup_commands: ['apt update'], - launch_parameters: { - resource_size_request: 'SMALL', - architecture: 'arm64' - } - }; - mockClient.blueprints.preview.mockResolvedValue(mockPreview); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - - const { previewBlueprint } = await import('@/commands/blueprint/preview'); - - await previewBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfile: 'FROM ubuntu:20.04', - systemSetupCommands: ['apt update'], - resources: 'SMALL', - architecture: 'arm64' - }); - - // Test passes if no error is thrown - expect(true).toBe(true); - }); - }); - - describe('getBlueprint', () => { - it('should retrieve blueprint details', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.retrieve.mockResolvedValue(mockBlueprintData); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - - const { getBlueprint } = await import('@/commands/blueprint/get'); - - await getBlueprint('bp-test-id', createMockCommandOptions()); - - // Test passes if no error is thrown - expect(true).toBe(true); - }); - - it('should handle blueprint not found', async () => { - mockClient.blueprints.retrieve.mockRejectedValue(new Error('Blueprint not found')); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - - const { getBlueprint } = await import('@/commands/blueprint/get'); - - await getBlueprint('nonexistent-id', createMockCommandOptions()); - - // Test passes if no error is thrown - expect(true).toBe(true); - }); - }); - - describe('logsBlueprint', () => { - it('should retrieve blueprint build logs', async () => { - const mockLogs = { - logs: [ - { - timestamp_ms: 1710000000000, - message: 'Building blueprint...', - level: 'info' - }, - { - timestamp_ms: 1710000001000, - message: 'Build completed successfully', - level: 'info' - } - ] - }; - mockClient.blueprints.logs.mockResolvedValue(mockLogs); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - - const { getBlueprintLogs } = await import('@/commands/blueprint/logs'); - - await getBlueprintLogs({ id: 'bp-test-id', ...createMockCommandOptions() }); - - // Test passes if no error is thrown - expect(true).toBe(true); - }); - - it('should handle blueprint logs not found', async () => { - mockClient.blueprints.logs.mockRejectedValue(new Error('Logs not found')); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - - const { getBlueprintLogs } = await import('@/commands/blueprint/logs'); - - await getBlueprintLogs({ id: 'nonexistent-id', ...createMockCommandOptions() }); - - // Test passes if no error is thrown - expect(true).toBe(true); - }); - }); - - describe('Blueprint Creation Edge Cases', () => { - it('should handle empty system setup commands', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.create.mockResolvedValue(mockBlueprintData); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - - const { createBlueprint } = await import('@/commands/blueprint/create'); - - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfile: 'FROM ubuntu:20.04', - systemSetupCommands: [], - resources: 'SMALL' - }); - - // Test passes if no error is thrown - expect(true).toBe(true); - }); - - it('should handle multiple available ports', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.create.mockResolvedValue(mockBlueprintData); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - - const { createBlueprint } = await import('@/commands/blueprint/create'); - - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfile: 'FROM ubuntu:20.04', - availablePorts: [3000, 8080, 9000], - resources: 'SMALL' - }); - - // Test passes if no error is thrown - expect(true).toBe(true); - }); - - it('should handle root user parameter', async () => { - const mockBlueprintData = mockBlueprint(); - mockClient.blueprints.create.mockResolvedValue(mockBlueprintData); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - - const { createBlueprint } = await import('@/commands/blueprint/create'); - - await createBlueprint({ - ...createMockCommandOptions(), - name: 'test-blueprint', - dockerfile: 'FROM ubuntu:20.04', - root: true, - resources: 'SMALL' - }); - - // Test passes if no error is thrown - expect(true).toBe(true); - }); - }); -}); - - diff --git a/tests/__tests__/unit/commands/devbox.test.js b/tests/__tests__/unit/commands/devbox.test.js deleted file mode 100644 index 052902b3..00000000 --- a/tests/__tests__/unit/commands/devbox.test.js +++ /dev/null @@ -1,310 +0,0 @@ -import { jest } from '@jest/globals'; -import { mockDevbox, mockAPIClient, mockSSHKey, mockLogEntry, mockExecution } from '../../../fixtures/mocks'; -import { mockSubprocess, mockFileSystem, mockNetwork, createMockCommandOptions } from '../../../helpers'; -// Mock the client and executor -jest.mock('@/utils/client', () => ({ - getClient: jest.fn() -})); -jest.mock('@/utils/CommandExecutor', () => ({ - createExecutor: jest.fn() -})); -// Mock fs/promises globally; individual tests will set behaviors -jest.mock('fs/promises', () => ({ - readFile: jest.fn(), - writeFile: jest.fn(), -})); -describe('Devbox Commands', () => { - let mockClient; - let mockExecutor; - beforeEach(() => { - jest.clearAllMocks(); - mockClient = mockAPIClient(); - mockExecutor = { - getClient: jest.fn().mockReturnValue(mockClient), - executeAction: jest.fn(async (action) => { - return await action(); - }) - }; - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - }); - describe('getDevbox', () => { - it('should retrieve devbox details', async () => { - const mockDevboxData = mockDevbox({ status: 'running' }); - mockClient.devboxes.retrieve.mockResolvedValue(mockDevboxData); - const { getDevbox } = await import('@/commands/devbox/get'); - await getDevbox('test-id', createMockCommandOptions()); - expect(mockClient.devboxes.retrieve).toHaveBeenCalledWith('test-id'); - expect(mockExecutor.executeAction).toHaveBeenCalled(); - }); - it('should handle devbox not found', async () => { - mockClient.devboxes.retrieve.mockRejectedValue(new Error('Not found')); - const { getDevbox } = await import('@/commands/devbox/get'); - await expect(getDevbox('nonexistent-id', createMockCommandOptions())) - .rejects.toThrow('Not found'); - }); - }); - describe('suspendDevbox', () => { - it('should suspend a devbox', async () => { - const mockSuspendedDevbox = mockDevbox({ status: 'suspended' }); - mockClient.devboxes.suspend.mockResolvedValue(mockSuspendedDevbox); - const { suspendDevbox } = await import('@/commands/devbox/suspend'); - await suspendDevbox('test-id', createMockCommandOptions()); - expect(mockClient.devboxes.suspend).toHaveBeenCalledWith('test-id'); - expect(mockExecutor.executeAction).toHaveBeenCalled(); - }); - }); - describe('resumeDevbox', () => { - it('should resume a suspended devbox', async () => { - const mockResumedDevbox = mockDevbox({ status: 'running' }); - mockClient.devboxes.resume.mockResolvedValue(mockResumedDevbox); - const { resumeDevbox } = await import('@/commands/devbox/resume'); - await resumeDevbox('test-id', createMockCommandOptions()); - expect(mockClient.devboxes.resume).toHaveBeenCalledWith('test-id'); - expect(mockExecutor.executeAction).toHaveBeenCalled(); - }); - }); - describe('shutdownDevbox', () => { - it('should shutdown a devbox', async () => { - const mockShutdownDevbox = mockDevbox({ status: 'shutdown' }); - mockClient.devboxes.shutdown.mockResolvedValue(mockShutdownDevbox); - const { shutdownDevbox } = await import('@/commands/devbox/shutdown'); - await shutdownDevbox('test-id', createMockCommandOptions()); - expect(mockClient.devboxes.shutdown).toHaveBeenCalledWith('test-id'); - expect(mockExecutor.executeAction).toHaveBeenCalled(); - }); - }); - describe('sshDevbox', () => { - const { mockRun } = mockSubprocess(); - const { mockExists, mockMkdir, mockChmod, mockFsync } = mockFileSystem(); - beforeEach(() => { - jest.doMock('@/utils/ssh', () => ({ - getSSHKey: jest.fn(async () => ({ keyfilePath: '/tmp/key', url: 'ssh://example' })), - waitForReady: jest.fn(async () => true), - generateSSHConfig: jest.fn(() => 'Host example\n User user'), - checkSSHTools: jest.fn(async () => true), - getProxyCommand: jest.fn(() => 'proxycmd') - })); - jest.doMock('child_process', () => ({ - spawn: jest.fn(), - exec: jest.fn() - })); - jest.doMock('fs', () => ({ - existsSync: mockExists, - mkdirSync: mockMkdir, - writeFileSync: jest.fn(), - chmodSync: mockChmod, - fsyncSync: mockFsync - })); - }); - it('should generate SSH key and connect', async () => { - const mockSSHKeyData = mockSSHKey(); - mockClient.devboxes.createSSHKey.mockResolvedValue(mockSSHKeyData); - mockClient.devboxes.retrieve.mockResolvedValue(mockDevbox()); - const { sshDevbox } = await import('@/commands/devbox/ssh'); - await sshDevbox('test-id', { - ...createMockCommandOptions(), - configOnly: false, - noWait: false - }); - expect(mockClient.devboxes.retrieve).toHaveBeenCalledWith('test-id'); - }); - it('should print SSH config when config-only is true', async () => { - const mockSSHKeyData = mockSSHKey(); - mockClient.devboxes.createSSHKey.mockResolvedValue(mockSSHKeyData); - mockClient.devboxes.retrieve.mockResolvedValue(mockDevbox()); - const { sshDevbox } = await import('@/commands/devbox/ssh'); - await sshDevbox('test-id', { - ...createMockCommandOptions(), - configOnly: true, - noWait: true - }); - // getSSHKey is used internally; we just verify no error thrown and mocks were used - }); - }); - describe('scpDevbox', () => { - const { mockRun } = mockSubprocess(); - beforeEach(() => { - jest.doMock('child_process', () => ({ - spawn: jest.fn(), - exec: jest.fn((cmd, cb) => cb(null, { stdout: '', stderr: '' })) - })); - jest.doMock('fs', () => ({ - writeFileSync: jest.fn(), - })); - }); - it('should execute scp command with correct arguments', async () => { - const mockSSHKeyData = mockSSHKey(); - mockClient.devboxes.createSSHKey.mockResolvedValue(mockSSHKeyData); - mockClient.devboxes.retrieve.mockResolvedValue(mockDevbox()); - const { scpFiles } = await import('@/commands/devbox/scp'); - await scpFiles('test-id', { - src: './local.txt', - dst: ':/remote.txt', - outputFormat: 'interactive' - }); - expect(mockClient.devboxes.retrieve).toHaveBeenCalledWith('test-id'); - }); - }); - describe('rsyncDevbox', () => { - const { mockRun } = mockSubprocess(); - beforeEach(() => { - jest.doMock('child_process', () => ({ - spawn: jest.fn(), - exec: jest.fn((cmd, cb) => cb(null, { stdout: '', stderr: '' })) - })); - }); - it('should execute rsync command with correct arguments', async () => { - const mockSSHKeyData = mockSSHKey(); - mockClient.devboxes.createSSHKey.mockResolvedValue(mockSSHKeyData); - mockClient.devboxes.retrieve.mockResolvedValue(mockDevbox()); - const { rsyncFiles } = await import('@/commands/devbox/rsync'); - await rsyncFiles('test-id', { - src: ':/remote_dir', - dst: './local_dir', - rsyncOptions: '-avz', - outputFormat: 'interactive' - }); - expect(mockClient.devboxes.retrieve).toHaveBeenCalledWith('test-id'); - }); - }); - describe('tunnelDevbox', () => { - const { mockRun } = mockSubprocess(); - beforeEach(() => { - jest.doMock('child_process', () => ({ - spawn: jest.fn(), - exec: jest.fn((cmd, cb) => cb(null, { stdout: '', stderr: '' })) - })); - }); - it('should create SSH tunnel with port forwarding', async () => { - const mockSSHKeyData = mockSSHKey(); - mockClient.devboxes.createSSHKey.mockResolvedValue(mockSSHKeyData); - mockClient.devboxes.retrieve.mockResolvedValue(mockDevbox()); - const { createTunnel } = await import('@/commands/devbox/tunnel'); - await createTunnel('test-id', { - ports: '8080:3000', - outputFormat: 'interactive' - }); - expect(mockClient.devboxes.retrieve).toHaveBeenCalledWith('test-id'); - }); - }); - describe('readFile', () => { - beforeEach(() => { - const fsPromises = jest.requireMock('fs/promises'); - fsPromises.writeFile.mockResolvedValue(undefined); - }); - it('should read file contents from devbox', async () => { - const fileContents = 'Hello, World!'; - mockClient.devboxes.readFileContents.mockResolvedValue(fileContents); - const { readFile } = await import('@/commands/devbox/read'); - await readFile('test-id', { - remote: '/path/to/remote/file.txt', - outputPath: '/path/to/local/file.txt', - output: 'interactive' - }); - expect(mockClient.devboxes.readFileContents).toHaveBeenCalledWith('test-id', { - file_path: '/path/to/remote/file.txt' - }); - }); - }); - describe('writeFile', () => { - beforeEach(() => { - const fsPromises = jest.requireMock('fs/promises'); - fsPromises.readFile.mockResolvedValue('local content'); - }); - it('should write file contents to devbox', async () => { - mockClient.devboxes.writeFileContents.mockResolvedValue(undefined); - const { writeFile } = await import('@/commands/devbox/write'); - await writeFile('test-id', { - input: '/path/to/local/file.txt', - remote: '/path/to/remote/file.txt', - output: 'interactive' - }); - expect(mockClient.devboxes.writeFileContents).toHaveBeenCalledWith('test-id', { - file_path: '/path/to/remote/file.txt', - contents: 'local content' - }); - }); - it('should handle file not found', async () => { - const fsPromises = jest.requireMock('fs/promises'); - fsPromises.readFile.mockRejectedValue(new Error('ENOENT')); - const { writeFile } = await import('@/commands/devbox/write'); - await expect(writeFile('test-id', { - input: '/nonexistent/file.txt', - remote: '/path/to/remote/file.txt', - output: 'interactive' - })).rejects.toThrow(); - }); - }); - describe('downloadFile', () => { - const { mockFetch } = mockNetwork(); - beforeEach(() => { - jest.doMock('node-fetch', () => mockFetch); - jest.doMock('fs', () => ({ - writeFileSync: jest.fn(), - })); - }); - it('should download file from devbox', async () => { - const mockResponse = { - download_url: 'https://example.com/download' - }; - mockClient.devboxes.downloadFile.mockResolvedValue(mockResponse); - // Use the default mock from mockNetwork - const { downloadFile } = await import('@/commands/devbox/download'); - await downloadFile('test-id', { - filePath: '/remote/file.txt', - outputPath: '/local/file.txt', - outputFormat: 'interactive' - }); - expect(mockClient.devboxes.downloadFile).toHaveBeenCalledWith('test-id', { - path: '/remote/file.txt' - }); - }); - }); - describe('execAsync', () => { - it('should execute command asynchronously', async () => { - const mockExecutionData = mockExecution(); - mockClient.devboxes.executeAsync.mockResolvedValue(mockExecutionData); - const { execAsync } = await import('@/commands/devbox/execAsync'); - await execAsync('test-id', { - command: 'echo hello', - output: 'interactive' - }); - expect(mockClient.devboxes.executeAsync).toHaveBeenCalledWith('test-id', { - command: 'echo hello', - shell_name: undefined - }); - }); - }); - describe('getAsync', () => { - it('should get async execution status', async () => { - const mockExecutionData = mockExecution({ status: 'completed' }); - mockClient.devboxes.executions.retrieve.mockResolvedValue(mockExecutionData); - const { getAsync } = await import('@/commands/devbox/getAsync'); - await getAsync('test-id', { - executionId: 'exec-123', - output: 'interactive' - }); - expect(mockClient.devboxes.executions.retrieve).toHaveBeenCalledWith('test-id', 'exec-123'); - }); - }); - describe('logsDevbox', () => { - it('should retrieve and format devbox logs', async () => { - const mockLogs = { - logs: [ - mockLogEntry({ timestamp_ms: 1710000000000, source: 'entrypoint', cmd: 'echo test' }), - mockLogEntry({ timestamp_ms: 1710000000500, message: 'hello' }), - mockLogEntry({ timestamp_ms: 1710000001000, exit_code: 0 }) - ] - }; - mockClient.devboxes.logs.list.mockResolvedValue(mockLogs); - const { getLogs } = await import('@/commands/devbox/logs'); - await getLogs('test-id', { output: 'interactive' }); - expect(mockClient.devboxes.logs.list).toHaveBeenCalledWith('test-id'); - }); - }); -}); diff --git a/tests/__tests__/unit/commands/devbox.test.ts b/tests/__tests__/unit/commands/devbox.test.ts deleted file mode 100644 index d554ff12..00000000 --- a/tests/__tests__/unit/commands/devbox.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { jest } from '@jest/globals'; -import { mockDevbox, mockAPIClient, mockSSHKey, mockLogEntry, mockExecution } from '../../../fixtures/mocks'; -import { mockSubprocess, mockFileSystem, mockNetwork, createMockCommandOptions } from '../../../helpers'; - -// Mock the client and executor -jest.mock('@/utils/client', () => ({ - getClient: jest.fn() -})); - -jest.mock('@/utils/CommandExecutor', () => ({ - createExecutor: jest.fn() -})); - -// Mock fs/promises globally; individual tests will set behaviors -jest.mock('fs/promises', () => ({ - readFile: jest.fn(), - writeFile: jest.fn(), -})); - -describe('Devbox Commands', () => { - let mockClient: any; - let mockExecutor: any; - - beforeEach(() => { - jest.clearAllMocks(); - - mockClient = mockAPIClient(); - mockExecutor = { - getClient: jest.fn().mockReturnValue(mockClient), - executeAction: jest.fn(async (action: any) => { - return await action(); - }) - }; - - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - }); - - describe('getDevbox', () => { - it('should retrieve devbox details', async () => { - const mockDevboxData = mockDevbox({ status: 'running' }); - mockClient.devboxes.retrieve.mockResolvedValue(mockDevboxData); - - const { getDevbox } = await import('@/commands/devbox/get'); - - await getDevbox('test-id', createMockCommandOptions()); - - expect(mockClient.devboxes.retrieve).toHaveBeenCalledWith('test-id'); - expect(mockExecutor.executeAction).toHaveBeenCalled(); - }); - - it('should handle devbox not found', async () => { - mockClient.devboxes.retrieve.mockRejectedValue(new Error('Not found')); - - const { getDevbox } = await import('@/commands/devbox/get'); - - await expect(getDevbox('nonexistent-id', createMockCommandOptions())) - .rejects.toThrow('Not found'); - }); - }); - - describe('suspendDevbox', () => { - it('should suspend a devbox', async () => { - const mockSuspendedDevbox = mockDevbox({ status: 'suspended' }); - mockClient.devboxes.suspend.mockResolvedValue(mockSuspendedDevbox); - - const { suspendDevbox } = await import('@/commands/devbox/suspend'); - - await suspendDevbox('test-id', createMockCommandOptions()); - - expect(mockClient.devboxes.suspend).toHaveBeenCalledWith('test-id'); - expect(mockExecutor.executeAction).toHaveBeenCalled(); - }); - }); - - describe('resumeDevbox', () => { - it('should resume a suspended devbox', async () => { - const mockResumedDevbox = mockDevbox({ status: 'running' }); - mockClient.devboxes.resume.mockResolvedValue(mockResumedDevbox); - - const { resumeDevbox } = await import('@/commands/devbox/resume'); - - await resumeDevbox('test-id', createMockCommandOptions()); - - expect(mockClient.devboxes.resume).toHaveBeenCalledWith('test-id'); - expect(mockExecutor.executeAction).toHaveBeenCalled(); - }); - }); - - describe('shutdownDevbox', () => { - it('should shutdown a devbox', async () => { - const mockShutdownDevbox = mockDevbox({ status: 'shutdown' }); - mockClient.devboxes.shutdown.mockResolvedValue(mockShutdownDevbox); - - const { shutdownDevbox } = await import('@/commands/devbox/shutdown'); - - await shutdownDevbox('test-id', createMockCommandOptions()); - - expect(mockClient.devboxes.shutdown).toHaveBeenCalledWith('test-id'); - expect(mockExecutor.executeAction).toHaveBeenCalled(); - }); - }); - - describe('sshDevbox', () => { - const { mockRun } = mockSubprocess(); - const { mockExists, mockMkdir, mockChmod, mockFsync } = mockFileSystem(); - - beforeEach(() => { - jest.doMock('@/utils/ssh', () => ({ - getSSHKey: jest.fn(async () => ({ keyfilePath: '/tmp/key', url: 'ssh://example' })), - waitForReady: jest.fn(async () => true), - generateSSHConfig: jest.fn(() => 'Host example\n User user'), - checkSSHTools: jest.fn(async () => true), - getProxyCommand: jest.fn(() => 'proxycmd') - })); - jest.doMock('child_process', () => ({ - spawn: jest.fn(), - exec: jest.fn() - })); - - jest.doMock('fs', () => ({ - existsSync: mockExists, - mkdirSync: mockMkdir, - writeFileSync: jest.fn(), - chmodSync: mockChmod, - fsyncSync: mockFsync - })); - }); - - it('should generate SSH key and connect', async () => { - const mockSSHKeyData = mockSSHKey(); - mockClient.devboxes.createSSHKey.mockResolvedValue(mockSSHKeyData); - mockClient.devboxes.retrieve.mockResolvedValue(mockDevbox()); - - const { sshDevbox } = await import('@/commands/devbox/ssh'); - - await sshDevbox('test-id', { - ...createMockCommandOptions(), - configOnly: false, - noWait: false - }); - - expect(mockClient.devboxes.retrieve).toHaveBeenCalledWith('test-id'); - }); - - it('should print SSH config when config-only is true', async () => { - const mockSSHKeyData = mockSSHKey(); - mockClient.devboxes.createSSHKey.mockResolvedValue(mockSSHKeyData); - mockClient.devboxes.retrieve.mockResolvedValue(mockDevbox()); - - const { sshDevbox } = await import('@/commands/devbox/ssh'); - - await sshDevbox('test-id', { - ...createMockCommandOptions(), - configOnly: true, - noWait: true - }); - - // getSSHKey is used internally; we just verify no error thrown and mocks were used - }); - }); - - describe('scpDevbox', () => { - const { mockRun } = mockSubprocess(); - - beforeEach(() => { - jest.doMock('child_process', () => ({ - spawn: jest.fn(), - exec: jest.fn((cmd: string, cb: Function) => cb(null, { stdout: '', stderr: '' })) - })); - jest.doMock('fs', () => ({ - writeFileSync: jest.fn(), - })); - }); - - it('should execute scp command with correct arguments', async () => { - const mockSSHKeyData = mockSSHKey(); - mockClient.devboxes.createSSHKey.mockResolvedValue(mockSSHKeyData); - mockClient.devboxes.retrieve.mockResolvedValue(mockDevbox()); - - const { scpFiles } = await import('@/commands/devbox/scp'); - - await scpFiles('test-id', { - src: './local.txt', - dst: ':/remote.txt', - outputFormat: 'interactive' - }); - - expect(mockClient.devboxes.retrieve).toHaveBeenCalledWith('test-id'); - }); - }); - - describe('rsyncDevbox', () => { - const { mockRun } = mockSubprocess(); - - beforeEach(() => { - jest.doMock('child_process', () => ({ - spawn: jest.fn(), - exec: jest.fn((cmd: string, cb: Function) => cb(null, { stdout: '', stderr: '' })) - })); - }); - - it('should execute rsync command with correct arguments', async () => { - const mockSSHKeyData = mockSSHKey(); - mockClient.devboxes.createSSHKey.mockResolvedValue(mockSSHKeyData); - mockClient.devboxes.retrieve.mockResolvedValue(mockDevbox()); - - const { rsyncFiles } = await import('@/commands/devbox/rsync'); - - await rsyncFiles('test-id', { - src: ':/remote_dir', - dst: './local_dir', - rsyncOptions: '-avz', - outputFormat: 'interactive' - }); - - expect(mockClient.devboxes.retrieve).toHaveBeenCalledWith('test-id'); - }); - }); - - describe('tunnelDevbox', () => { - const { mockRun } = mockSubprocess(); - - beforeEach(() => { - jest.doMock('child_process', () => ({ - spawn: jest.fn(), - exec: jest.fn((cmd: string, cb: Function) => cb(null, { stdout: '', stderr: '' })) - })); - }); - - it('should create SSH tunnel with port forwarding', async () => { - const mockSSHKeyData = mockSSHKey(); - mockClient.devboxes.createSSHKey.mockResolvedValue(mockSSHKeyData); - mockClient.devboxes.retrieve.mockResolvedValue(mockDevbox()); - - const { createTunnel } = await import('@/commands/devbox/tunnel'); - - await createTunnel('test-id', { - ports: '8080:3000', - outputFormat: 'interactive' - }); - - expect(mockClient.devboxes.retrieve).toHaveBeenCalledWith('test-id'); - }); - }); - - describe('readFile', () => { - beforeEach(() => { - const fsPromises: any = jest.requireMock('fs/promises'); - fsPromises.writeFile.mockResolvedValue(undefined); - }); - it('should read file contents from devbox', async () => { - const fileContents = 'Hello, World!'; - mockClient.devboxes.readFileContents.mockResolvedValue(fileContents); - - const { readFile } = await import('@/commands/devbox/read'); - - await readFile('test-id', { - remote: '/path/to/remote/file.txt', - outputPath: '/path/to/local/file.txt', - output: 'interactive' - }); - - expect(mockClient.devboxes.readFileContents).toHaveBeenCalledWith('test-id', { - file_path: '/path/to/remote/file.txt' - }); - }); - }); - - describe('writeFile', () => { - beforeEach(() => { - const fsPromises: any = jest.requireMock('fs/promises'); - fsPromises.readFile.mockResolvedValue('local content'); - }); - - it('should write file contents to devbox', async () => { - mockClient.devboxes.writeFileContents.mockResolvedValue(undefined); - - const { writeFile } = await import('@/commands/devbox/write'); - - await writeFile('test-id', { - input: '/path/to/local/file.txt', - remote: '/path/to/remote/file.txt', - output: 'interactive' - }); - - expect(mockClient.devboxes.writeFileContents).toHaveBeenCalledWith('test-id', { - file_path: '/path/to/remote/file.txt', - contents: 'local content' - }); - }); - - it('should handle file not found', async () => { - const fsPromises: any = jest.requireMock('fs/promises'); - fsPromises.readFile.mockRejectedValue(new Error('ENOENT')); - - const { writeFile } = await import('@/commands/devbox/write'); - - await expect(writeFile('test-id', { - input: '/nonexistent/file.txt', - remote: '/path/to/remote/file.txt', - output: 'interactive' - })).rejects.toThrow(); - }); - }); - - describe('downloadFile', () => { - const { mockFetch } = mockNetwork(); - - beforeEach(() => { - jest.doMock('node-fetch', () => mockFetch); - jest.doMock('fs', () => ({ - writeFileSync: jest.fn(), - })); - }); - - it('should download file from devbox', async () => { - const mockResponse = { - download_url: 'https://example.com/download' - }; - mockClient.devboxes.downloadFile.mockResolvedValue(mockResponse); - // Use the default mock from mockNetwork - - const { downloadFile } = await import('@/commands/devbox/download'); - - await downloadFile('test-id', { - filePath: '/remote/file.txt', - outputPath: '/local/file.txt', - outputFormat: 'interactive' - }); - - expect(mockClient.devboxes.downloadFile).toHaveBeenCalledWith('test-id', { - path: '/remote/file.txt' - }); - }); - }); - - describe('execAsync', () => { - it('should execute command asynchronously', async () => { - const mockExecutionData = mockExecution(); - mockClient.devboxes.executeAsync.mockResolvedValue(mockExecutionData); - - const { execAsync } = await import('@/commands/devbox/execAsync'); - - await execAsync('test-id', { - command: 'echo hello', - output: 'interactive' - }); - - expect(mockClient.devboxes.executeAsync).toHaveBeenCalledWith('test-id', { - command: 'echo hello', - shell_name: undefined - }); - }); - }); - - describe('getAsync', () => { - it('should get async execution status', async () => { - const mockExecutionData = mockExecution({ status: 'completed' }); - mockClient.devboxes.executions.retrieve.mockResolvedValue(mockExecutionData); - - const { getAsync } = await import('@/commands/devbox/getAsync'); - - await getAsync('test-id', { - executionId: 'exec-123', - output: 'interactive' - }); - - expect(mockClient.devboxes.executions.retrieve).toHaveBeenCalledWith('test-id', 'exec-123'); - }); - }); - - describe('logsDevbox', () => { - it('should retrieve and format devbox logs', async () => { - const mockLogs = { - logs: [ - mockLogEntry({ timestamp_ms: 1710000000000, source: 'entrypoint', cmd: 'echo test' }), - mockLogEntry({ timestamp_ms: 1710000000500, message: 'hello' }), - mockLogEntry({ timestamp_ms: 1710000001000, exit_code: 0 }) - ] - }; - mockClient.devboxes.logs.list.mockResolvedValue(mockLogs); - - const { getLogs } = await import('@/commands/devbox/logs'); - - await getLogs('test-id', { output: 'interactive' }); - - expect(mockClient.devboxes.logs.list).toHaveBeenCalledWith('test-id'); - }); - }); -}); - - diff --git a/tests/__tests__/unit/commands/object.test.js b/tests/__tests__/unit/commands/object.test.js deleted file mode 100644 index a1f99e38..00000000 --- a/tests/__tests__/unit/commands/object.test.js +++ /dev/null @@ -1,446 +0,0 @@ -import { jest } from '@jest/globals'; -import { mockObject, mockAPIClient } from '../../../fixtures/mocks'; -import { createMockCommandOptions, mockNetwork, mockFileSystem } from '../../../helpers'; -describe('Object Commands', () => { - let mockClient; - let mockExecutor; - beforeEach(() => { - jest.clearAllMocks(); - mockClient = mockAPIClient(); - mockExecutor = { - getClient: jest.fn().mockReturnValue(mockClient), - executeAction: jest.fn().mockImplementation(async (fetchData, renderUI) => { - const result = await fetchData(); - return result; - }), - executeList: jest.fn().mockImplementation(async (fetchData, renderUI, limit) => { - const items = await fetchData(); - return items; - }) - }; - // Mock the modules before each test - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - }); - describe('listObjects', () => { - it('should list objects with default parameters', async () => { - const mockObjects = { - objects: [ - mockObject({ id: 'obj-1', name: 'file1.txt' }), - mockObject({ id: 'obj-2', name: 'file2.txt' }) - ] - }; - mockClient.objects.list.mockResolvedValue(mockObjects); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - const { listObjects } = await import('@/commands/object/list'); - await listObjects(createMockCommandOptions()); - expect(mockClient.objects.list).toHaveBeenCalledWith({ - limit: undefined, - name: undefined, - content_type: undefined, - state: undefined, - search: undefined, - public: undefined - }); - }); - it('should list objects with filters', async () => { - const mockObjects = { - objects: [mockObject({ name: 'test.txt', content_type: 'text/plain' })] - }; - mockClient.objects.list.mockResolvedValue(mockObjects); - mockClient.objects.listPublic.mockResolvedValue(mockObjects); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - const { listObjects } = await import('@/commands/object/list'); - await listObjects({ - ...createMockCommandOptions(), - limit: 10, - name: 'test', - contentType: 'text/plain', - state: 'READ_ONLY', - search: 'test query', - public: true - }); - expect(mockClient.objects.listPublic).toHaveBeenCalledWith({ - limit: 10, - name: 'test', - contentType: 'text/plain', - state: 'READ_ONLY', - search: 'test query', - isPublic: true - }); - }); - }); - describe('getObject', () => { - it('should retrieve object details', async () => { - const mockObjectData = mockObject(); - mockClient.objects.retrieve.mockResolvedValue(mockObjectData); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - const { getObject } = await import('@/commands/object/get'); - await getObject({ id: 'obj-test-id', ...createMockCommandOptions() }); - expect(mockClient.objects.retrieve).toHaveBeenCalledWith('obj-test-id'); - expect(mockExecutor.executeAction).toHaveBeenCalled(); - }); - it('should handle object not found', async () => { - mockClient.objects.retrieve.mockRejectedValue(new Error('Object not found')); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - const { getObject } = await import('@/commands/object/get'); - await expect(getObject({ id: 'nonexistent-id', ...createMockCommandOptions() })) - .rejects.toThrow('Object not found'); - }); - }); - describe('downloadObject', () => { - const { mockFetch } = mockNetwork(); - beforeEach(() => { - jest.doMock('node-fetch', () => mockFetch); - }); - it('should download object with presigned URL', async () => { - const mockDownloadResponse = { - download_url: 'https://example.com/download/obj-test-id' - }; - mockClient.objects.download.mockResolvedValue(mockDownloadResponse); - mockFetch.mockResolvedValue({ - ok: true, - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)) - }); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - // Mock global fetch for download requests - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)) - }); - // Mock fs/promises for file operations - jest.doMock('fs/promises', () => ({ - writeFile: jest.fn().mockResolvedValue(undefined) - })); - const { downloadObject } = await import('@/commands/object/download'); - await downloadObject({ - id: 'obj-test-id', - path: '/path/to/output.txt', - durationSeconds: 3600, - ...createMockCommandOptions() - }); - expect(mockClient.objects.download).toHaveBeenCalledWith('obj-test-id', { - duration_seconds: 3600 - }); - }); - it('should download object with default duration', async () => { - const mockDownloadResponse = { - download_url: 'https://example.com/download/obj-test-id' - }; - mockClient.objects.download.mockResolvedValue(mockDownloadResponse); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - // Mock global fetch for download requests - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)) - }); - // Mock fs/promises for file operations - jest.doMock('fs/promises', () => ({ - writeFile: jest.fn().mockResolvedValue(undefined) - })); - const { downloadObject } = await import('@/commands/object/download'); - await downloadObject({ - id: 'obj-test-id', - path: '/path/to/output.txt', - ...createMockCommandOptions() - }); - expect(mockClient.objects.download).toHaveBeenCalledWith('obj-test-id', { - duration_seconds: 3600 - }); - }); - it('should handle download failure', async () => { - mockClient.objects.download.mockRejectedValue(new Error('Download failed')); - const { downloadObject } = await import('@/commands/object/download'); - await expect(downloadObject('obj-test-id', { - ...createMockCommandOptions(), - outputPath: '/path/to/output.txt' - })).rejects.toThrow('Download failed'); - }); - }); - describe('uploadObject', () => { - const { mockExists, mockMkdir, mockChmod, mockFsync } = mockFileSystem(); - const mockFs = { - existsSync: jest.fn(), - statSync: jest.fn(), - readFileSync: jest.fn() - }; - beforeEach(() => { - jest.doMock('fs', () => mockFs); - }); - it('should upload file as object', async () => { - const mockCreateResponse = { - id: 'obj-test-id', - upload_url: 'https://example.com/upload', - fields: { key: 'value' } - }; - const mockCompleteResponse = mockObject(); - mockClient.objects.create.mockResolvedValue(mockCreateResponse); - mockClient.objects.complete.mockResolvedValue(mockCompleteResponse); - mockFs.existsSync.mockReturnValue(true); - mockFs.statSync.mockReturnValue({ size: 1024 }); - mockFs.readFileSync.mockReturnValue('file content'); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - jest.doMock('fs/promises', () => ({ - stat: jest.fn().mockResolvedValue({ size: 1024 }), - readFile: jest.fn().mockResolvedValue(Buffer.from('file content')) - })); - // Mock global fetch for upload requests - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200 - }); - const { uploadObject } = await import('@/commands/object/upload'); - await uploadObject({ - ...createMockCommandOptions(), - path: '/path/to/file.txt', - name: 'test-object', - contentType: 'text/plain', - public: false - }); - expect(mockClient.objects.create).toHaveBeenCalledWith({ - name: 'test-object', - content_type: 'text/plain' - }); - expect(mockClient.objects.complete).toHaveBeenCalledWith('obj-test-id'); - }); - it('should auto-detect content type from file extension', async () => { - const mockCreateResponse = { - object_id: 'obj-test-id', - upload_url: 'https://example.com/upload', - fields: { key: 'value' } - }; - const mockCompleteResponse = mockObject(); - mockClient.objects.create.mockResolvedValue(mockCreateResponse); - mockClient.objects.complete.mockResolvedValue(mockCompleteResponse); - mockFs.existsSync.mockReturnValue(true); - mockFs.statSync.mockReturnValue({ size: 1024 }); - mockFs.readFileSync.mockReturnValue('file content'); - const { uploadObject } = await import('@/commands/object/upload'); - await uploadObject({ - ...createMockCommandOptions(), - path: '/path/to/file.json', - name: 'test-object' - }); - expect(mockClient.objects.create).toHaveBeenCalledWith({ - name: 'test-object', - content_type: 'text' - }); - }); - it('should handle file not found', async () => { - mockFs.existsSync.mockReturnValue(false); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - jest.doMock('fs/promises', () => ({ - stat: jest.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')), - readFile: jest.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')) - })); - const { uploadObject } = await import('@/commands/object/upload'); - await expect(uploadObject({ - ...createMockCommandOptions(), - path: '/nonexistent/file.txt', - name: 'test-object' - })).rejects.toThrow('ENOENT: no such file or directory'); - }); - it('should handle upload failure', async () => { - mockClient.objects.create.mockRejectedValue(new Error('Upload failed')); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - jest.doMock('fs/promises', () => ({ - stat: jest.fn().mockResolvedValue({ size: 1024 }), - readFile: jest.fn().mockResolvedValue(Buffer.from('file content')) - })); - const { uploadObject } = await import('@/commands/object/upload'); - await expect(uploadObject({ - ...createMockCommandOptions(), - path: '/path/to/file.txt', - name: 'test-object' - })).rejects.toThrow('Upload failed'); - }); - }); - describe('deleteObject', () => { - it('should delete object', async () => { - const mockDeletedObject = mockObject({ state: 'DELETED' }); - mockClient.objects.delete.mockResolvedValue(mockDeletedObject); - const { deleteObject } = await import('@/commands/object/delete'); - await deleteObject({ - ...createMockCommandOptions(), - id: 'obj-test-id' - }); - expect(mockClient.objects.delete).toHaveBeenCalledWith('obj-test-id'); - }); - it('should handle delete failure', async () => { - mockClient.objects.delete.mockRejectedValue(new Error('Delete failed')); - const { deleteObject } = await import('@/commands/object/delete'); - await expect(deleteObject({ - ...createMockCommandOptions(), - id: 'obj-test-id' - })).rejects.toThrow('Delete failed'); - }); - }); - describe('Content Type Detection', () => { - const mockFs = { - existsSync: jest.fn(), - statSync: jest.fn(), - readFileSync: jest.fn() - }; - beforeEach(() => { - jest.doMock('fs', () => mockFs); - }); - it('should detect common content types', async () => { - const testCases = [ - { file: 'test.txt', expected: 'text' }, - { file: 'test.json', expected: 'text' }, - { file: 'test.html', expected: 'text' }, - { file: 'test.css', expected: 'text' }, - { file: 'test.js', expected: 'text' }, - { file: 'test.png', expected: 'unspecified' }, - { file: 'test.jpg', expected: 'unspecified' }, - { file: 'test.pdf', expected: 'unspecified' }, - { file: 'test.zip', expected: 'unspecified' }, - { file: 'test.tar.gz', expected: 'gzip' } // extname returns .gz, not .tar.gz - ]; - for (const testCase of testCases) { - const mockCreateResponse = { - object_id: 'obj-test-id', - upload_url: 'https://example.com/upload', - fields: { key: 'value' } - }; - const mockCompleteResponse = mockObject(); - mockClient.objects.create.mockResolvedValue(mockCreateResponse); - mockClient.objects.complete.mockResolvedValue(mockCompleteResponse); - mockFs.existsSync.mockReturnValue(true); - mockFs.statSync.mockReturnValue({ size: 1024 }); - mockFs.readFileSync.mockReturnValue('file content'); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - jest.doMock('fs', () => mockFs); - jest.doMock('fs/promises', () => ({ - stat: jest.fn().mockResolvedValue({ size: 1024 }), - readFile: jest.fn().mockResolvedValue(Buffer.from('file content')) - })); - // Mock global fetch for upload requests - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200 - }); - const { uploadObject } = await import('@/commands/object/upload'); - await uploadObject({ - ...createMockCommandOptions(), - path: `/path/to/${testCase.file}`, - name: 'test-object' - }); - expect(mockClient.objects.create).toHaveBeenCalledWith({ - name: 'test-object', - content_type: testCase.expected - }); - } - }); - }); - describe('Object State Management', () => { - it('should handle different object states', async () => { - const states = ['READ_ONLY', 'WRITE_ONLY', 'READ_WRITE', 'DELETED']; - for (const state of states) { - const mockObjectData = mockObject({ state }); - mockClient.objects.retrieve.mockResolvedValue(mockObjectData); - // Clear module cache and import dynamically - jest.resetModules(); - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - const { getObject } = await import('@/commands/object/get'); - await getObject({ id: 'obj-test-id', ...createMockCommandOptions() }); - expect(mockClient.objects.retrieve).toHaveBeenCalledWith('obj-test-id'); - } - }); - }); -}); diff --git a/tests/__tests__/unit/commands/object.test.ts b/tests/__tests__/unit/commands/object.test.ts deleted file mode 100644 index 4a1da15f..00000000 --- a/tests/__tests__/unit/commands/object.test.ts +++ /dev/null @@ -1,565 +0,0 @@ -import { jest } from '@jest/globals'; -import { mockObject, mockAPIClient } from '../../../fixtures/mocks'; -import { createMockCommandOptions, mockNetwork, mockFileSystem } from '../../../helpers'; - -describe('Object Commands', () => { - let mockClient: any; - let mockExecutor: any; - - beforeEach(() => { - jest.clearAllMocks(); - - mockClient = mockAPIClient(); - mockExecutor = { - getClient: jest.fn().mockReturnValue(mockClient), - executeAction: jest.fn().mockImplementation(async (fetchData, renderUI) => { - const result = await fetchData(); - return result; - }), - executeList: jest.fn().mockImplementation(async (fetchData, renderUI, limit) => { - const items = await fetchData(); - return items; - }) - }; - - // Mock the modules before each test - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - }); - - describe('listObjects', () => { - it('should list objects with default parameters', async () => { - const mockObjects = { - objects: [ - mockObject({ id: 'obj-1', name: 'file1.txt' }), - mockObject({ id: 'obj-2', name: 'file2.txt' }) - ] - }; - mockClient.objects.list.mockResolvedValue(mockObjects); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - const { listObjects } = await import('@/commands/object/list'); - - await listObjects(createMockCommandOptions()); - - expect(mockClient.objects.list).toHaveBeenCalledWith({ - limit: undefined, - name: undefined, - content_type: undefined, - state: undefined, - search: undefined, - public: undefined - }); - }); - - it('should list objects with filters', async () => { - const mockObjects = { - objects: [mockObject({ name: 'test.txt', content_type: 'text/plain' })] - }; - mockClient.objects.list.mockResolvedValue(mockObjects); - mockClient.objects.listPublic.mockResolvedValue(mockObjects); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - const { listObjects } = await import('@/commands/object/list'); - - await listObjects({ - ...createMockCommandOptions(), - limit: 10, - name: 'test', - contentType: 'text/plain', - state: 'READ_ONLY', - search: 'test query', - public: true - }); - - expect(mockClient.objects.listPublic).toHaveBeenCalledWith({ - limit: 10, - name: 'test', - contentType: 'text/plain', - state: 'READ_ONLY', - search: 'test query', - isPublic: true - }); - }); - }); - - describe('getObject', () => { - it('should retrieve object details', async () => { - const mockObjectData = mockObject(); - mockClient.objects.retrieve.mockResolvedValue(mockObjectData); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - const { getObject } = await import('@/commands/object/get'); - - await getObject({ id: 'obj-test-id', ...createMockCommandOptions() }); - - expect(mockClient.objects.retrieve).toHaveBeenCalledWith('obj-test-id'); - expect(mockExecutor.executeAction).toHaveBeenCalled(); - }); - - it('should handle object not found', async () => { - mockClient.objects.retrieve.mockRejectedValue(new Error('Object not found')); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - const { getObject } = await import('@/commands/object/get'); - - await expect(getObject({ id: 'nonexistent-id', ...createMockCommandOptions() })) - .rejects.toThrow('Object not found'); - }); - }); - - describe('downloadObject', () => { - const { mockFetch } = mockNetwork(); - - beforeEach(() => { - jest.doMock('node-fetch', () => mockFetch); - }); - - it('should download object with presigned URL', async () => { - const mockDownloadResponse = { - download_url: 'https://example.com/download/obj-test-id' - }; - mockClient.objects.download.mockResolvedValue(mockDownloadResponse); - mockFetch.mockResolvedValue({ - ok: true, - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)) - }); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - // Mock global fetch for download requests - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)) - }); - - // Mock fs/promises for file operations - jest.doMock('fs/promises', () => ({ - writeFile: jest.fn().mockResolvedValue(undefined) - })); - - const { downloadObject } = await import('@/commands/object/download'); - - await downloadObject({ - id: 'obj-test-id', - path: '/path/to/output.txt', - durationSeconds: 3600, - ...createMockCommandOptions() - }); - - expect(mockClient.objects.download).toHaveBeenCalledWith('obj-test-id', { - duration_seconds: 3600 - }); - }); - - it('should download object with default duration', async () => { - const mockDownloadResponse = { - download_url: 'https://example.com/download/obj-test-id' - }; - mockClient.objects.download.mockResolvedValue(mockDownloadResponse); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - // Mock global fetch for download requests - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)) - }); - - // Mock fs/promises for file operations - jest.doMock('fs/promises', () => ({ - writeFile: jest.fn().mockResolvedValue(undefined) - })); - - const { downloadObject } = await import('@/commands/object/download'); - - await downloadObject({ - id: 'obj-test-id', - path: '/path/to/output.txt', - ...createMockCommandOptions() - }); - - expect(mockClient.objects.download).toHaveBeenCalledWith('obj-test-id', { - duration_seconds: 3600 - }); - }); - - it('should handle download failure', async () => { - mockClient.objects.download.mockRejectedValue(new Error('Download failed')); - - const { downloadObject } = await import('@/commands/object/download'); - - await expect(downloadObject('obj-test-id', { - ...createMockCommandOptions(), - outputPath: '/path/to/output.txt' - })).rejects.toThrow('Download failed'); - }); - }); - - describe('uploadObject', () => { - const { mockExists, mockMkdir, mockChmod, mockFsync } = mockFileSystem(); - const mockFs = { - existsSync: jest.fn(), - statSync: jest.fn(), - readFileSync: jest.fn() - }; - - beforeEach(() => { - jest.doMock('fs', () => mockFs); - }); - - it('should upload file as object', async () => { - const mockCreateResponse = { - id: 'obj-test-id', - upload_url: 'https://example.com/upload', - fields: { key: 'value' } - }; - const mockCompleteResponse = mockObject(); - - mockClient.objects.create.mockResolvedValue(mockCreateResponse); - mockClient.objects.complete.mockResolvedValue(mockCompleteResponse); - - mockFs.existsSync.mockReturnValue(true); - mockFs.statSync.mockReturnValue({ size: 1024 }); - mockFs.readFileSync.mockReturnValue('file content'); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - jest.doMock('fs/promises', () => ({ - stat: jest.fn().mockResolvedValue({ size: 1024 }), - readFile: jest.fn().mockResolvedValue(Buffer.from('file content')) - })); - - // Mock global fetch for upload requests - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200 - }); - - const { uploadObject } = await import('@/commands/object/upload'); - - await uploadObject({ - ...createMockCommandOptions(), - path: '/path/to/file.txt', - name: 'test-object', - contentType: 'text/plain', - public: false - }); - - expect(mockClient.objects.create).toHaveBeenCalledWith({ - name: 'test-object', - content_type: 'text/plain' - }); - expect(mockClient.objects.complete).toHaveBeenCalledWith('obj-test-id'); - }); - - it('should auto-detect content type from file extension', async () => { - const mockCreateResponse = { - object_id: 'obj-test-id', - upload_url: 'https://example.com/upload', - fields: { key: 'value' } - }; - const mockCompleteResponse = mockObject(); - - mockClient.objects.create.mockResolvedValue(mockCreateResponse); - mockClient.objects.complete.mockResolvedValue(mockCompleteResponse); - - mockFs.existsSync.mockReturnValue(true); - mockFs.statSync.mockReturnValue({ size: 1024 }); - mockFs.readFileSync.mockReturnValue('file content'); - - const { uploadObject } = await import('@/commands/object/upload'); - - await uploadObject({ - ...createMockCommandOptions(), - path: '/path/to/file.json', - name: 'test-object' - }); - - expect(mockClient.objects.create).toHaveBeenCalledWith({ - name: 'test-object', - content_type: 'text' - }); - }); - - it('should handle file not found', async () => { - mockFs.existsSync.mockReturnValue(false); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - jest.doMock('fs/promises', () => ({ - stat: jest.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')), - readFile: jest.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')) - })); - - const { uploadObject } = await import('@/commands/object/upload'); - - await expect(uploadObject({ - ...createMockCommandOptions(), - path: '/nonexistent/file.txt', - name: 'test-object' - })).rejects.toThrow('ENOENT: no such file or directory'); - }); - - it('should handle upload failure', async () => { - mockClient.objects.create.mockRejectedValue(new Error('Upload failed')); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - jest.doMock('fs/promises', () => ({ - stat: jest.fn().mockResolvedValue({ size: 1024 }), - readFile: jest.fn().mockResolvedValue(Buffer.from('file content')) - })); - - const { uploadObject } = await import('@/commands/object/upload'); - - await expect(uploadObject({ - ...createMockCommandOptions(), - path: '/path/to/file.txt', - name: 'test-object' - })).rejects.toThrow('Upload failed'); - }); - }); - - describe('deleteObject', () => { - it('should delete object', async () => { - const mockDeletedObject = mockObject({ state: 'DELETED' }); - mockClient.objects.delete.mockResolvedValue(mockDeletedObject); - - const { deleteObject } = await import('@/commands/object/delete'); - - await deleteObject({ - ...createMockCommandOptions(), - id: 'obj-test-id' - }); - - expect(mockClient.objects.delete).toHaveBeenCalledWith('obj-test-id'); - }); - - it('should handle delete failure', async () => { - mockClient.objects.delete.mockRejectedValue(new Error('Delete failed')); - - const { deleteObject } = await import('@/commands/object/delete'); - - await expect(deleteObject({ - ...createMockCommandOptions(), - id: 'obj-test-id' - })).rejects.toThrow('Delete failed'); - }); - }); - - describe('Content Type Detection', () => { - const mockFs = { - existsSync: jest.fn(), - statSync: jest.fn(), - readFileSync: jest.fn() - }; - - beforeEach(() => { - jest.doMock('fs', () => mockFs); - }); - - it('should detect common content types', async () => { - const testCases = [ - { file: 'test.txt', expected: 'text' }, - { file: 'test.json', expected: 'text' }, - { file: 'test.html', expected: 'text' }, - { file: 'test.css', expected: 'text' }, - { file: 'test.js', expected: 'text' }, - { file: 'test.png', expected: 'unspecified' }, - { file: 'test.jpg', expected: 'unspecified' }, - { file: 'test.pdf', expected: 'unspecified' }, - { file: 'test.zip', expected: 'unspecified' }, - { file: 'test.tar.gz', expected: 'gzip' } // extname returns .gz, not .tar.gz - ]; - - for (const testCase of testCases) { - const mockCreateResponse = { - object_id: 'obj-test-id', - upload_url: 'https://example.com/upload', - fields: { key: 'value' } - }; - const mockCompleteResponse = mockObject(); - - mockClient.objects.create.mockResolvedValue(mockCreateResponse); - mockClient.objects.complete.mockResolvedValue(mockCompleteResponse); - - mockFs.existsSync.mockReturnValue(true); - mockFs.statSync.mockReturnValue({ size: 1024 }); - mockFs.readFileSync.mockReturnValue('file content'); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - jest.doMock('fs', () => mockFs); - jest.doMock('fs/promises', () => ({ - stat: jest.fn().mockResolvedValue({ size: 1024 }), - readFile: jest.fn().mockResolvedValue(Buffer.from('file content')) - })); - - // Mock global fetch for upload requests - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200 - }); - - const { uploadObject } = await import('@/commands/object/upload'); - - await uploadObject({ - ...createMockCommandOptions(), - path: `/path/to/${testCase.file}`, - name: 'test-object' - }); - - expect(mockClient.objects.create).toHaveBeenCalledWith({ - name: 'test-object', - content_type: testCase.expected - }); - } - }); - }); - - describe('Object State Management', () => { - it('should handle different object states', async () => { - const states = ['READ_ONLY', 'WRITE_ONLY', 'READ_WRITE', 'DELETED']; - - for (const state of states) { - const mockObjectData = mockObject({ state }); - mockClient.objects.retrieve.mockResolvedValue(mockObjectData); - - // Clear module cache and import dynamically - jest.resetModules(); - - // Re-setup mocks after clearing modules - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('@/utils/CommandExecutor', () => ({ - createExecutor: () => mockExecutor - })); - - const { getObject } = await import('@/commands/object/get'); - - await getObject({ id: 'obj-test-id', ...createMockCommandOptions() }); - - expect(mockClient.objects.retrieve).toHaveBeenCalledWith('obj-test-id'); - } - }); - }); -}); - - diff --git a/tests/__tests__/unit/output.test.js b/tests/__tests__/unit/output.test.js deleted file mode 100644 index 97f89369..00000000 --- a/tests/__tests__/unit/output.test.js +++ /dev/null @@ -1,131 +0,0 @@ -import { shouldUseNonInteractiveOutput, outputData, outputResult, outputList, } from "@/utils/output"; -describe("Output Utility", () => { - describe("shouldUseNonInteractiveOutput", () => { - it("should return true for json output", () => { - const options = { output: "json" }; - expect(shouldUseNonInteractiveOutput(options)).toBe(true); - }); - it("should return true for yaml output", () => { - const options = { output: "yaml" }; - expect(shouldUseNonInteractiveOutput(options)).toBe(true); - }); - it("should return true for text output", () => { - const options = { output: "text" }; - expect(shouldUseNonInteractiveOutput(options)).toBe(true); - }); - it("should return false for interactive output", () => { - const options = { output: "interactive" }; - expect(shouldUseNonInteractiveOutput(options)).toBe(false); - }); - it("should return false when output is undefined", () => { - const options = {}; - expect(shouldUseNonInteractiveOutput(options)).toBe(false); - }); - it("should return false when output is empty string", () => { - const options = { output: "" }; - expect(shouldUseNonInteractiveOutput(options)).toBe(false); - }); - }); - describe("outputData", () => { - let consoleSpy; - beforeEach(() => { - consoleSpy = jest.spyOn(console, "log").mockImplementation(); - }); - afterEach(() => { - consoleSpy.mockRestore(); - }); - it("should output JSON format by default", () => { - const testData = { id: "test", name: "test-name" }; - outputData(testData); - expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(testData, null, 2)); - }); - it("should output JSON format when specified", () => { - const testData = { id: "test", name: "test-name" }; - outputData(testData, "json"); - expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(testData, null, 2)); - }); - it("should output YAML format when specified", () => { - const testData = { id: "test", name: "test-name" }; - outputData(testData, "yaml"); - expect(consoleSpy).toHaveBeenCalledWith("id: test\nname: test-name\n"); - }); - it("should output text format when specified", () => { - const testData = { id: "test", name: "test-name" }; - outputData(testData, "text"); - expect(consoleSpy).toHaveBeenCalledWith("id: test\nname: test-name"); - }); - it("should handle arrays in text format", () => { - const testData = ["item1", "item2", "item3"]; - outputData(testData, "text"); - expect(consoleSpy).toHaveBeenCalledWith("item1"); - expect(consoleSpy).toHaveBeenCalledWith("item2"); - expect(consoleSpy).toHaveBeenCalledWith("item3"); - }); - it("should handle nested objects in text format", () => { - const testData = { - id: "test", - metadata: { - key1: "value1", - key2: "value2" - } - }; - outputData(testData, "text"); - expect(consoleSpy).toHaveBeenCalledWith("id: test\nmetadata: [object Object]"); - }); - }); - describe("outputResult", () => { - let consoleSpy; - beforeEach(() => { - consoleSpy = jest.spyOn(console, "log").mockImplementation(); - }); - afterEach(() => { - consoleSpy.mockRestore(); - }); - it("should output result in non-interactive mode", () => { - const result = { id: "test", status: "success" }; - const options = { output: "json" }; - outputResult(result, options); - expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(result, null, 2)); - }); - it("should not output result in interactive mode", () => { - const result = { id: "test", status: "success" }; - const options = {}; - outputResult(result, options); - expect(consoleSpy).not.toHaveBeenCalled(); - }); - it("should output success message in interactive mode", () => { - const result = { id: "test", status: "success" }; - const options = {}; - const successMessage = "Operation completed successfully"; - outputResult(result, options, successMessage); - expect(consoleSpy).toHaveBeenCalledWith(successMessage); - }); - }); - describe("outputList", () => { - let consoleSpy; - beforeEach(() => { - consoleSpy = jest.spyOn(console, "log").mockImplementation(); - }); - afterEach(() => { - consoleSpy.mockRestore(); - }); - it("should output list in non-interactive mode", () => { - const items = [ - { id: "item1", name: "Item 1" }, - { id: "item2", name: "Item 2" } - ]; - const options = { output: "json" }; - outputList(items, options); - expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(items, null, 2)); - }); - it("should not output list in interactive mode", () => { - const items = [ - { id: "item1", name: "Item 1" }, - { id: "item2", name: "Item 2" } - ]; - const options = {}; - outputList(items, options); - expect(consoleSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/tests/__tests__/unit/output.test.ts b/tests/__tests__/unit/output.test.ts deleted file mode 100644 index 6c295716..00000000 --- a/tests/__tests__/unit/output.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { - shouldUseNonInteractiveOutput, - outputData, - outputResult, - outputList, - OutputOptions, -} from "@/utils/output"; - -describe("Output Utility", () => { - describe("shouldUseNonInteractiveOutput", () => { - it("should return true for json output", () => { - const options: OutputOptions = { output: "json" }; - expect(shouldUseNonInteractiveOutput(options)).toBe(true); - }); - - it("should return true for yaml output", () => { - const options: OutputOptions = { output: "yaml" }; - expect(shouldUseNonInteractiveOutput(options)).toBe(true); - }); - - it("should return true for text output", () => { - const options: OutputOptions = { output: "text" }; - expect(shouldUseNonInteractiveOutput(options)).toBe(true); - }); - - it("should return false for interactive output", () => { - const options: OutputOptions = { output: "interactive" }; - expect(shouldUseNonInteractiveOutput(options)).toBe(false); - }); - - it("should return false when output is undefined", () => { - const options: OutputOptions = {}; - expect(shouldUseNonInteractiveOutput(options)).toBe(false); - }); - - it("should return false when output is empty string", () => { - const options: OutputOptions = { output: "" }; - expect(shouldUseNonInteractiveOutput(options)).toBe(false); - }); - }); - - describe("outputData", () => { - let consoleSpy: jest.SpyInstance; - - beforeEach(() => { - consoleSpy = jest.spyOn(console, "log").mockImplementation(); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it("should output JSON format by default", () => { - const testData = { id: "test", name: "test-name" }; - outputData(testData); - - expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(testData, null, 2)); - }); - - it("should output JSON format when specified", () => { - const testData = { id: "test", name: "test-name" }; - outputData(testData, "json"); - - expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(testData, null, 2)); - }); - - it("should output YAML format when specified", () => { - const testData = { id: "test", name: "test-name" }; - outputData(testData, "yaml"); - - expect(consoleSpy).toHaveBeenCalledWith("id: test\nname: test-name\n"); - }); - - it("should output text format when specified", () => { - const testData = { id: "test", name: "test-name" }; - outputData(testData, "text"); - - expect(consoleSpy).toHaveBeenCalledWith("id: test\nname: test-name"); - }); - - it("should handle arrays in text format", () => { - const testData = ["item1", "item2", "item3"]; - outputData(testData, "text"); - - expect(consoleSpy).toHaveBeenCalledWith("item1"); - expect(consoleSpy).toHaveBeenCalledWith("item2"); - expect(consoleSpy).toHaveBeenCalledWith("item3"); - }); - - it("should handle nested objects in text format", () => { - const testData = { - id: "test", - metadata: { - key1: "value1", - key2: "value2" - } - }; - outputData(testData, "text"); - - expect(consoleSpy).toHaveBeenCalledWith("id: test\nmetadata: [object Object]"); - }); - }); - - describe("outputResult", () => { - let consoleSpy: jest.SpyInstance; - - beforeEach(() => { - consoleSpy = jest.spyOn(console, "log").mockImplementation(); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it("should output result in non-interactive mode", () => { - const result = { id: "test", status: "success" }; - const options: OutputOptions = { output: "json" }; - - outputResult(result, options); - - expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(result, null, 2)); - }); - - it("should not output result in interactive mode", () => { - const result = { id: "test", status: "success" }; - const options: OutputOptions = {}; - - outputResult(result, options); - - expect(consoleSpy).not.toHaveBeenCalled(); - }); - - it("should output success message in interactive mode", () => { - const result = { id: "test", status: "success" }; - const options: OutputOptions = {}; - const successMessage = "Operation completed successfully"; - - outputResult(result, options, successMessage); - - expect(consoleSpy).toHaveBeenCalledWith(successMessage); - }); - }); - - describe("outputList", () => { - let consoleSpy: jest.SpyInstance; - - beforeEach(() => { - consoleSpy = jest.spyOn(console, "log").mockImplementation(); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it("should output list in non-interactive mode", () => { - const items = [ - { id: "item1", name: "Item 1" }, - { id: "item2", name: "Item 2" } - ]; - const options: OutputOptions = { output: "json" }; - - outputList(items, options); - - expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(items, null, 2)); - }); - - it("should not output list in interactive mode", () => { - const items = [ - { id: "item1", name: "Item 1" }, - { id: "item2", name: "Item 2" } - ]; - const options: OutputOptions = {}; - - outputList(items, options); - - expect(consoleSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/tests/__tests__/unit/setup.test.js b/tests/__tests__/unit/setup.test.js deleted file mode 100644 index 4cbb8100..00000000 --- a/tests/__tests__/unit/setup.test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { jest } from '@jest/globals'; -describe('Jest Setup', () => { - it('should run basic tests', () => { - expect(1 + 1).toBe(2); - }); - it('should have access to jest globals', () => { - expect(jest).toBeDefined(); - }); - it('should have access to environment variables', () => { - expect(process.env.RUNLOOP_ENV).toBe('dev'); - }); -}); diff --git a/tests/__tests__/unit/setup.test.ts b/tests/__tests__/unit/setup.test.ts deleted file mode 100644 index 739e890a..00000000 --- a/tests/__tests__/unit/setup.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { jest } from '@jest/globals'; - -describe('Jest Setup', () => { - it('should run basic tests', () => { - expect(1 + 1).toBe(2); - }); - - it('should have access to jest globals', () => { - expect(jest).toBeDefined(); - }); - - it('should have access to environment variables', () => { - expect(process.env.RUNLOOP_ENV).toBe('dev'); - }); -}); - - diff --git a/tests/__tests__/unit/utils.test.js b/tests/__tests__/unit/utils.test.js deleted file mode 100644 index 45a05555..00000000 --- a/tests/__tests__/unit/utils.test.js +++ /dev/null @@ -1,187 +0,0 @@ -import { jest } from '@jest/globals'; -import { join } from 'path'; -import { homedir } from 'os'; -// Mock the client module -jest.mock('@/utils/client', () => ({ - getClient: jest.fn() -})); -// Mock environment variables -const originalEnv = process.env; -describe('Utility Functions', () => { - beforeEach(() => { - jest.clearAllMocks(); - process.env = { ...originalEnv }; - }); - afterEach(() => { - process.env = originalEnv; - }); - describe('Base URL Resolution', () => { - it('should return dev URL when RUNLOOP_ENV is dev', () => { - process.env.RUNLOOP_ENV = 'dev'; - // Import after setting env - const { baseUrl } = require('@/utils/config'); - expect(baseUrl()).toBe('https://api.runloop.pro'); - }); - it('should return prod URL when RUNLOOP_ENV is not set', () => { - delete process.env.RUNLOOP_ENV; - const { baseUrl } = require('@/utils/config'); - expect(baseUrl()).toBe('https://api.runloop.ai'); - }); - it('should return prod URL when RUNLOOP_ENV is prod', () => { - process.env.RUNLOOP_ENV = 'prod'; - const { baseUrl } = require('@/utils/config'); - expect(baseUrl()).toBe('https://api.runloop.ai'); - }); - }); - describe('SSH URL Resolution', () => { - it('should return dev SSH URL when RUNLOOP_ENV is dev', () => { - process.env.RUNLOOP_ENV = 'dev'; - const { sshUrl } = require('@/utils/config'); - expect(sshUrl()).toBe('ssh.runloop.pro:443'); - }); - it('should return prod SSH URL when RUNLOOP_ENV is not set', () => { - delete process.env.RUNLOOP_ENV; - const { sshUrl } = require('@/utils/config'); - expect(sshUrl()).toBe('ssh.runloop.ai:443'); - }); - }); - describe('Cache Directory Management', () => { - it('should return correct cache directory path', () => { - const { getCacheDir } = require('@/utils/config'); - const expected = join(homedir(), '.cache', 'rl-cli'); - expect(getCacheDir()).toBe(expected); - }); - }); - describe('Update Checking Logic', () => { - const mockFs = { - existsSync: jest.fn(), - statSync: jest.fn(), - utimesSync: jest.fn() - }; - beforeEach(() => { - jest.doMock('fs', () => mockFs); - }); - it('should return true when no cache exists', () => { - mockFs.existsSync.mockReturnValue(false); - const { shouldCheckForUpdates } = require('@/utils/config'); - expect(shouldCheckForUpdates()).toBe(true); - }); - it('should return false for recent cache', () => { - mockFs.existsSync.mockReturnValue(true); - mockFs.statSync.mockReturnValue({ - mtime: new Date(Date.now() - 1000 * 60 * 60 * 12) // 12 hours ago (less than 1 day) - }); - // Clear module cache to ensure fresh import - jest.resetModules(); - const { shouldCheckForUpdates } = require('@/utils/config'); - expect(shouldCheckForUpdates()).toBe(false); - }); - it('should return true for old cache', () => { - mockFs.existsSync.mockReturnValue(true); - mockFs.statSync.mockReturnValue({ - mtime: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2) // 2 days ago - }); - // Clear module cache to ensure fresh import - jest.resetModules(); - const { shouldCheckForUpdates } = require('@/utils/config'); - expect(shouldCheckForUpdates()).toBe(true); - }); - }); - describe('Environment Variable Handling', () => { - it('should handle missing API key gracefully', () => { - delete process.env.RUNLOOP_API_KEY; - const { getClient } = require('@/utils/client'); - expect(() => getClient()).not.toThrow(); - }); - it('should use provided API key', () => { - process.env.RUNLOOP_API_KEY = 'test-key'; - // Clear module cache to ensure fresh import - jest.resetModules(); - const { getClient } = require('@/utils/client'); - const client = getClient(); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - }); - describe('URL Utilities', () => { - it('should construct SSH config correctly', () => { - const { constructSSHConfig } = require('@/utils/ssh'); - const config = constructSSHConfig({ - hostname: 'test-host', - username: 'test-user', - keyPath: '/path/to/key', - port: 443 - }); - expect(config).toContain('Host test-host'); - expect(config).toContain('User test-user'); - expect(config).toContain('IdentityFile /path/to/key'); - expect(config).toContain('Port 443'); - }); - }); - describe('SSH Key Management', () => { - const mockFs = { - mkdirSync: jest.fn(), - writeFileSync: jest.fn(), - chmodSync: jest.fn(), - fsyncSync: jest.fn() - }; - beforeEach(() => { - jest.doMock('fs', () => mockFs); - }); - it('should create SSH key file with correct permissions', async () => { - const mockClient = { - devboxes: { - createSSHKey: jest.fn().mockResolvedValue({ - ssh_private_key: 'test-key', - url: 'test-host' - }) - } - }; - // Clear module cache and import dynamically - jest.resetModules(); - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('fs', () => mockFs); - const { getSSHKey } = require('@/utils/ssh'); - const result = await getSSHKey('test-devbox-id'); - expect(result).toBeDefined(); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - it('should handle SSH key creation failure', async () => { - const mockClient = { - devboxes: { - createSSHKey: jest.fn().mockResolvedValue(null) - } - }; - // Clear module cache and import dynamically - jest.resetModules(); - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - jest.doMock('fs', () => mockFs); - const { getSSHKey } = require('@/utils/ssh'); - const result = await getSSHKey('test-devbox-id'); - expect(result).toBeNull(); - }); - }); - describe('Command Executor', () => { - it('should create executor with correct options', () => { - const { createExecutor } = require('@/utils/CommandExecutor'); - const executor = createExecutor({ output: 'json' }); - expect(executor).toBeDefined(); - expect(executor.getClient).toBeDefined(); - expect(executor.executeAction).toBeDefined(); - }); - it('should handle different output formats', () => { - const { createExecutor } = require('@/utils/CommandExecutor'); - const jsonExecutor = createExecutor({ output: 'json' }); - const yamlExecutor = createExecutor({ output: 'yaml' }); - const textExecutor = createExecutor({ output: 'text' }); - expect(jsonExecutor).toBeDefined(); - expect(yamlExecutor).toBeDefined(); - expect(textExecutor).toBeDefined(); - }); - }); -}); diff --git a/tests/__tests__/unit/utils.test.ts b/tests/__tests__/unit/utils.test.ts deleted file mode 100644 index ac6b4138..00000000 --- a/tests/__tests__/unit/utils.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { jest } from '@jest/globals'; -import { join } from 'path'; -import { homedir } from 'os'; - -// Mock the client module -jest.mock('@/utils/client', () => ({ - getClient: jest.fn() -})); - -// Mock environment variables -const originalEnv = process.env; - -describe('Utility Functions', () => { - beforeEach(() => { - jest.clearAllMocks(); - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - describe('Base URL Resolution', () => { - it('should return dev URL when RUNLOOP_ENV is dev', () => { - process.env.RUNLOOP_ENV = 'dev'; - - // Import after setting env - const { baseUrl } = require('@/utils/config'); - expect(baseUrl()).toBe('https://api.runloop.pro'); - }); - - it('should return prod URL when RUNLOOP_ENV is not set', () => { - delete process.env.RUNLOOP_ENV; - - const { baseUrl } = require('@/utils/config'); - expect(baseUrl()).toBe('https://api.runloop.ai'); - }); - - it('should return prod URL when RUNLOOP_ENV is prod', () => { - process.env.RUNLOOP_ENV = 'prod'; - - const { baseUrl } = require('@/utils/config'); - expect(baseUrl()).toBe('https://api.runloop.ai'); - }); - }); - - describe('SSH URL Resolution', () => { - it('should return dev SSH URL when RUNLOOP_ENV is dev', () => { - process.env.RUNLOOP_ENV = 'dev'; - - const { sshUrl } = require('@/utils/config'); - expect(sshUrl()).toBe('ssh.runloop.pro:443'); - }); - - it('should return prod SSH URL when RUNLOOP_ENV is not set', () => { - delete process.env.RUNLOOP_ENV; - - const { sshUrl } = require('@/utils/config'); - expect(sshUrl()).toBe('ssh.runloop.ai:443'); - }); - }); - - describe('Cache Directory Management', () => { - it('should return correct cache directory path', () => { - const { getCacheDir } = require('@/utils/config'); - const expected = join(homedir(), '.cache', 'rl-cli'); - expect(getCacheDir()).toBe(expected); - }); - }); - - describe('Update Checking Logic', () => { - const mockFs = { - existsSync: jest.fn(), - statSync: jest.fn(), - utimesSync: jest.fn() - }; - - beforeEach(() => { - jest.doMock('fs', () => mockFs); - }); - - it('should return true when no cache exists', () => { - mockFs.existsSync.mockReturnValue(false); - - const { shouldCheckForUpdates } = require('@/utils/config'); - expect(shouldCheckForUpdates()).toBe(true); - }); - - it('should return false for recent cache', () => { - mockFs.existsSync.mockReturnValue(true); - mockFs.statSync.mockReturnValue({ - mtime: new Date(Date.now() - 1000 * 60 * 60 * 12) // 12 hours ago (less than 1 day) - }); - - // Clear module cache to ensure fresh import - jest.resetModules(); - - const { shouldCheckForUpdates } = require('@/utils/config'); - expect(shouldCheckForUpdates()).toBe(false); - }); - - it('should return true for old cache', () => { - mockFs.existsSync.mockReturnValue(true); - mockFs.statSync.mockReturnValue({ - mtime: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2) // 2 days ago - }); - - // Clear module cache to ensure fresh import - jest.resetModules(); - - const { shouldCheckForUpdates } = require('@/utils/config'); - expect(shouldCheckForUpdates()).toBe(true); - }); - }); - - describe('Environment Variable Handling', () => { - it('should handle missing API key gracefully', () => { - delete process.env.RUNLOOP_API_KEY; - - const { getClient } = require('@/utils/client'); - expect(() => getClient()).not.toThrow(); - }); - - it('should use provided API key', () => { - process.env.RUNLOOP_API_KEY = 'test-key'; - - // Clear module cache to ensure fresh import - jest.resetModules(); - - const { getClient } = require('@/utils/client'); - const client = getClient(); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - }); - - describe('URL Utilities', () => { - it('should construct SSH config correctly', () => { - const { constructSSHConfig } = require('@/utils/ssh'); - - const config = constructSSHConfig({ - hostname: 'test-host', - username: 'test-user', - keyPath: '/path/to/key', - port: 443 - }); - - expect(config).toContain('Host test-host'); - expect(config).toContain('User test-user'); - expect(config).toContain('IdentityFile /path/to/key'); - expect(config).toContain('Port 443'); - }); - }); - - describe('SSH Key Management', () => { - const mockFs = { - mkdirSync: jest.fn(), - writeFileSync: jest.fn(), - chmodSync: jest.fn(), - fsyncSync: jest.fn() - }; - - beforeEach(() => { - jest.doMock('fs', () => mockFs); - }); - - it('should create SSH key file with correct permissions', async () => { - const mockClient = { - devboxes: { - createSSHKey: jest.fn().mockResolvedValue({ - ssh_private_key: 'test-key', - url: 'test-host' - }) - } - }; - - // Clear module cache and import dynamically - jest.resetModules(); - - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('fs', () => mockFs); - - const { getSSHKey } = require('@/utils/ssh'); - - const result = await getSSHKey('test-devbox-id'); - - expect(result).toBeDefined(); - // Test passes if no error is thrown - expect(true).toBe(true); - }); - - it('should handle SSH key creation failure', async () => { - const mockClient = { - devboxes: { - createSSHKey: jest.fn().mockResolvedValue(null) - } - }; - - // Clear module cache and import dynamically - jest.resetModules(); - - jest.doMock('@/utils/client', () => ({ - getClient: () => mockClient - })); - - jest.doMock('fs', () => mockFs); - - const { getSSHKey } = require('@/utils/ssh'); - - const result = await getSSHKey('test-devbox-id'); - - expect(result).toBeNull(); - }); - }); - - describe('Command Executor', () => { - it('should create executor with correct options', () => { - const { createExecutor } = require('@/utils/CommandExecutor'); - - const executor = createExecutor({ output: 'json' }); - expect(executor).toBeDefined(); - expect(executor.getClient).toBeDefined(); - expect(executor.executeAction).toBeDefined(); - }); - - it('should handle different output formats', () => { - const { createExecutor } = require('@/utils/CommandExecutor'); - - const jsonExecutor = createExecutor({ output: 'json' }); - const yamlExecutor = createExecutor({ output: 'yaml' }); - const textExecutor = createExecutor({ output: 'text' }); - - expect(jsonExecutor).toBeDefined(); - expect(yamlExecutor).toBeDefined(); - expect(textExecutor).toBeDefined(); - }); - }); -}); - - diff --git a/tests/helpers.js b/tests/helpers.js deleted file mode 100644 index 9cb75de0..00000000 --- a/tests/helpers.js +++ /dev/null @@ -1,65 +0,0 @@ -import { jest } from '@jest/globals'; -import { writeFileSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -export const createTempFile = (content, extension = '.txt') => { - const tempDir = tmpdir(); - const tempFile = join(tempDir, `test-${Date.now()}${extension}`); - writeFileSync(tempFile, content); - return tempFile; -}; -export const createTempDir = () => { - const tempDir = join(tmpdir(), `test-${Date.now()}`); - mkdirSync(tempDir, { recursive: true }); - return tempDir; -}; -export const mockSubprocess = () => { - const mockRun = jest.fn(); - const mockSpawn = jest.fn(); - // Mock successful execution - mockRun.mockResolvedValue({ - stdout: 'success', - stderr: '', - exitCode: 0 - }); - mockSpawn.mockReturnValue({ - stdout: { on: jest.fn() }, - stderr: { on: jest.fn() }, - on: jest.fn(), - kill: jest.fn() - }); - return { mockRun, mockSpawn }; -}; -export const mockFileSystem = () => { - const mockExists = jest.fn(); - const mockMkdir = jest.fn(); - const mockChmod = jest.fn(); - const mockFsync = jest.fn(); - mockExists.mockReturnValue(true); - mockMkdir.mockImplementation(() => { }); - mockChmod.mockImplementation(() => { }); - mockFsync.mockImplementation(() => { }); - return { mockExists, mockMkdir, mockChmod, mockFsync }; -}; -export const mockNetwork = () => { - const mockFetch = jest.fn(); - // Set up default mock response - using any to avoid typing issues - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - json: jest.fn(), - text: jest.fn(), - arrayBuffer: jest.fn() - }); - return { mockFetch }; -}; -export const waitFor = (ms) => { - return new Promise(resolve => setTimeout(resolve, ms)); -}; -export const expectToHaveBeenCalledWithAPI = (mockFn, expectedCall) => { - expect(mockFn).toHaveBeenCalledWith(expect.objectContaining(expectedCall)); -}; -export const createMockCommandOptions = (overrides = {}) => ({ - output: 'interactive', - ...overrides -}); diff --git a/tests/setup-components.ts b/tests/setup-components.ts new file mode 100644 index 00000000..aafc22ec --- /dev/null +++ b/tests/setup-components.ts @@ -0,0 +1,301 @@ +/** + * Test setup for component tests using ink-testing-library. + * This setup does NOT mock Ink, allowing real rendering tests. + */ + +import { jest } from "@jest/globals"; +import { config } from "dotenv"; +import { existsSync } from "fs"; +import { join } from "path"; + +// Load .env file if it exists +const envPath = join(process.cwd(), ".env"); +if (existsSync(envPath)) { + config({ path: envPath }); +} + +// Set default test environment variables +process.env.RUNLOOP_ENV = process.env.RUNLOOP_ENV || "dev"; +process.env.RUNLOOP_API_KEY = process.env.RUNLOOP_API_KEY || "ak_test_key"; +process.env.RUNLOOP_BASE_URL = + process.env.RUNLOOP_BASE_URL || "https://api.runloop.pro"; +process.env.NODE_ENV = process.env.NODE_ENV || "test"; + +// Mock console methods for cleaner test output +const originalConsole = global.console; +global.console = { + ...originalConsole, + error: jest.fn(), + log: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +}; + +// Mock processUtils to prevent actual process.exit calls in tests +jest.mock("../src/utils/processUtils", () => ({ + processUtils: { + exit: jest.fn((code?: number) => { + throw new Error(`process.exit(${code}) called`); + }), + stdout: { + write: jest.fn(() => true), + isTTY: false, + }, + stderr: { + write: jest.fn(() => true), + isTTY: false, + }, + stdin: { + isTTY: false, + setRawMode: jest.fn(), + on: jest.fn(), + removeListener: jest.fn(), + }, + cwd: jest.fn(() => "/mock/cwd"), + on: jest.fn(), + off: jest.fn(), + env: process.env, + }, + resetProcessUtils: jest.fn(), + createMockProcessUtils: jest.fn(), + installMockProcessUtils: jest.fn(), +})); + +// Mock signal-exit to avoid ESM teardown issues +jest.mock("signal-exit", () => ({ + __esModule: true, + default: jest.fn(() => () => {}), + onExit: jest.fn(() => () => {}), +})); + +// Mock ESM-only dependencies that cause issues +jest.mock("figures", () => ({ + __esModule: true, + default: { + tick: "✓", + cross: "✗", + bullet: "●", + circle: "○", + circleFilled: "●", + circleDotted: "◌", + ellipsis: "…", + questionMarkPrefix: "?", + arrowRight: "→", + arrowDown: "↓", + arrowUp: "↑", + arrowLeft: "←", + pointer: "❯", + pointerSmall: "›", + info: "ℹ", + warning: "⚠", + hamburger: "☰", + play: "▶", + squareSmallFilled: "◼", + identical: "≡", + }, +})); + +jest.mock("conf", () => { + return { + __esModule: true, + default: class MockConf { + private store: Record = {}; + get(key: string) { + return this.store[key]; + } + set(key: string, value: unknown) { + this.store[key] = value; + } + delete(key: string) { + delete this.store[key]; + } + has(key: string) { + return key in this.store; + } + clear() { + this.store = {}; + } + }, + }; +}); + +// Mock ink-spinner to avoid ESM issues +jest.mock("ink-spinner", () => ({ + __esModule: true, + default: () => null, +})); + +// Mock ink-big-text and ink-gradient (these cause ESM issues) +jest.mock("ink-big-text", () => ({ __esModule: true, default: () => null })); +jest.mock("ink-gradient", () => ({ __esModule: true, default: () => null })); + +// Note: We do NOT mock 'ink' - we use ink-testing-library which needs real ink + +// Mock ink-text-input +jest.mock("ink-text-input", () => ({ + __esModule: true, + default: ({ value, placeholder }: { value?: string; placeholder?: string }) => + value || placeholder || "", +})); + +// Mock services to avoid API calls during tests +jest.mock("../src/services/devboxService.ts", () => ({ + devboxService: { + list: jest.fn().mockResolvedValue({ devboxes: [], hasMore: false }), + get: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: "test-id" }), + delete: jest.fn().mockResolvedValue(undefined), + shutdown: jest.fn().mockResolvedValue(undefined), + }, + getDevboxLogs: jest.fn().mockResolvedValue([]), + execCommand: jest + .fn() + .mockResolvedValue({ stdout: "", stderr: "", exit_code: 0 }), + suspendDevbox: jest.fn().mockResolvedValue(undefined), + resumeDevbox: jest.fn().mockResolvedValue(undefined), + shutdownDevbox: jest.fn().mockResolvedValue(undefined), + uploadFile: jest.fn().mockResolvedValue(undefined), + createSnapshot: jest.fn().mockResolvedValue({ id: "snap-test" }), + createTunnel: jest.fn().mockResolvedValue({ url: "https://tunnel.test" }), + createSSHKey: jest + .fn() + .mockResolvedValue({ ssh_private_key: "key", url: "test" }), + getDevbox: jest.fn().mockResolvedValue(null), +})); + +jest.mock("../src/services/blueprintService.ts", () => ({ + blueprintService: { + list: jest.fn().mockResolvedValue({ blueprints: [], hasMore: false }), + get: jest.fn().mockResolvedValue(null), + }, +})); + +jest.mock("../src/services/snapshotService.ts", () => ({ + snapshotService: { + list: jest.fn().mockResolvedValue({ snapshots: [], hasMore: false }), + get: jest.fn().mockResolvedValue(null), + }, +})); + +// Mock zustand stores +jest.mock("../src/store/devboxStore.ts", () => ({ + useDevboxStore: jest.fn(() => ({ + devboxes: [], + loading: false, + error: null, + fetchDevboxes: jest.fn(), + selectedDevbox: null, + setSelectedDevbox: jest.fn(), + })), +})); + +// Note: navigationStore is .tsx not .ts - mock both possible import paths +jest.mock("../src/store/navigationStore", () => ({ + useNavigationStore: jest.fn(() => ({ + currentRoute: "/", + navigate: jest.fn(), + goBack: jest.fn(), + breadcrumbs: [], + })), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + currentScreen: "home", + params: {}, + })), +})); + +// Mock hooks +jest.mock("../src/hooks/useViewportHeight.ts", () => ({ + useViewportHeight: jest.fn(() => ({ + viewportHeight: 20, + terminalHeight: 24, + terminalWidth: 80, + })), +})); + +jest.mock("../src/hooks/useExitOnCtrlC.ts", () => ({ + useExitOnCtrlC: jest.fn(), +})); + +// Mock version.ts VERSION export +jest.mock("../src/version", () => ({ + VERSION: "0.1.0", +})); + +// Mock theme utilities +jest.mock("../src/utils/theme.ts", () => ({ + colors: { + primary: "#00ff00", + secondary: "#0000ff", + success: "#00ff00", + error: "#ff0000", + warning: "#ffff00", + info: "#00ffff", + text: "#ffffff", + textDim: "#888888", + border: "#444444", + background: "#000000", + accent1: "#ff00ff", + accent2: "#00ffff", + accent3: "#ffff00", + idColor: "#888888", + }, + isLightMode: jest.fn(() => false), + getChalkColor: jest.fn(() => "#ffffff"), + getChalkTextColor: jest.fn(() => (text: string) => text), + sanitizeWidth: jest.fn((width: number, min?: number, max?: number) => + Math.max(min || 1, Math.min(width, max || 100)), + ), +})); + +// Mock url utility +jest.mock("../src/utils/url.ts", () => ({ + getDevboxUrl: jest.fn((id: string) => `https://runloop.ai/devbox/${id}`), +})); + +// Mock logFormatter +jest.mock("../src/utils/logFormatter.ts", () => ({ + parseAnyLogEntry: jest.fn( + (log: { level?: string; source?: string; message?: string }) => ({ + timestamp: new Date().toISOString(), + level: log.level || "INFO", + source: log.source || "system", + message: log.message || "", + levelColor: "gray", + sourceColor: "gray", + cmd: null, + exitCode: null, + shellName: null, + }), + ), +})); + +// Mock client +jest.mock("../src/utils/client.ts", () => ({ + getClient: jest.fn(() => ({ + devboxes: { + create: jest.fn().mockResolvedValue({ id: "test-id", status: "running" }), + list: jest.fn().mockResolvedValue({ data: [] }), + }, + })), +})); + +// Mock screen utilities +jest.mock("../src/utils/screen.ts", () => ({ + showCursor: jest.fn(), + clearScreen: jest.fn(), + enterAlternateScreenBuffer: jest.fn(), +})); + +// Mock Banner component (uses ink-big-text which is ESM) +jest.mock("../src/components/Banner.tsx", () => ({ + __esModule: true, + Banner: () => null, +})); + +// Mock UpdateNotification to avoid network calls during breadcrumb tests +jest.mock("../src/components/UpdateNotification.tsx", () => ({ + __esModule: true, + UpdateNotification: () => null, +})); diff --git a/tests/setup.js b/tests/setup.js deleted file mode 100644 index 5a0ec6cf..00000000 --- a/tests/setup.js +++ /dev/null @@ -1,65 +0,0 @@ -// Load environment variables from .env file if it exists -import { config } from 'dotenv'; -import { existsSync } from 'fs'; -import { join } from 'path'; -// Load .env file if it exists -const envPath = join(process.cwd(), '.env'); -if (existsSync(envPath)) { - config({ path: envPath }); -} -// Set default test environment variables -process.env.RUNLOOP_ENV = process.env.RUNLOOP_ENV || 'dev'; -process.env.RUNLOOP_API_KEY = process.env.RUNLOOP_API_KEY || 'ak_30tbdSzn9RNLxkrgpeT81'; -process.env.RUNLOOP_BASE_URL = process.env.RUNLOOP_BASE_URL || 'https://api.runloop.pro'; -process.env.NODE_ENV = process.env.NODE_ENV || 'test'; -// Mock console methods for cleaner test output (only for unit tests) -if (!process.env.RUN_E2E) { - const originalConsole = global.console; - global.console = { - ...originalConsole, - error: jest.fn(), - log: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - debug: jest.fn(), - }; -} -// Mock process.exit to prevent test runner from exiting (only for unit tests) -if (!process.env.RUN_E2E) { - const originalExit = process.exit; - process.exit = jest.fn(); -} -// Add pending function for integration tests -global.pending = (reason) => { - throw new Error(`Test pending: ${reason || 'No reason provided'}`); -}; -// Mock interactive command runner to prevent Ink issues in tests -jest.mock('../src/utils/interactiveCommand.js', () => ({ - runInteractiveCommand: jest.fn(async (fn) => { - // Just run the function directly without Ink - return await fn(); - }), -})); -// Mock Ink components to prevent raw mode issues -jest.mock('ink', () => ({ - render: jest.fn(), - Box: ({ children }) => children, - Text: ({ children }) => children, - useInput: jest.fn(), - useStdout: jest.fn(() => ({ stdout: { write: jest.fn() } })), - useStderr: jest.fn(() => ({ stderr: { write: jest.fn() } })), - useFocus: jest.fn(), - useFocusManager: jest.fn(), - useApp: jest.fn(), - measureElement: jest.fn(), - useMeasure: jest.fn(), -})); -// Mock ESM-only Ink dependencies so Jest doesn't parse their ESM bundles -jest.mock('ink-big-text', () => ({ __esModule: true, default: () => null })); -jest.mock('ink-gradient', () => ({ __esModule: true, default: () => null })); -// Mock app UI components that import Ink deps, to avoid pulling in ESM from node_modules -jest.mock('../src/components/Banner.tsx', () => ({ __esModule: true, Banner: () => null })); -jest.mock('../src/components/Header.tsx', () => ({ __esModule: true, Header: () => null })); -jest.mock('../src/components/Spinner.tsx', () => ({ __esModule: true, SpinnerComponent: () => null })); -jest.mock('../src/components/SuccessMessage.tsx', () => ({ __esModule: true, SuccessMessage: () => null })); -jest.mock('../src/components/ErrorMessage.tsx', () => ({ __esModule: true, ErrorMessage: () => null }));