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 }));