#!/usr/bin/env tsx /** * E2E Scenario Tester for Cloud Instances API * * Tests complete user workflows against the deployed API * Run: npx tsx scripts/e2e-tester.ts [--scenario ] [--dry-run] */ import process from 'process'; // ============================================================ // Configuration // ============================================================ const API_URL = 'https://cloud-instances-api.kappa-d8e.workers.dev'; const API_KEY = '0f955192075f7d36b1432ec985713ac6aba7fe82ffa556e6f45381c5530ca042'; interface TestContext { recommendedInstanceId?: string; linodeInstanceCount?: number; tokyoInstances?: number; seoulInstances?: number; } // ============================================================ // Utility Functions // ============================================================ /** * Make API request with proper headers and error handling */ async function apiRequest( endpoint: string, options: RequestInit = {} ): Promise<{ status: number; data: unknown; duration: number; headers: Headers }> { const startTime = Date.now(); const url = `${API_URL}${endpoint}`; const response = await fetch(url, { ...options, headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json', ...options.headers, }, }); const duration = Date.now() - startTime; let data: unknown; try { data = await response.json(); } catch (err) { data = { error: 'Failed to parse JSON response', rawText: await response.text() }; } return { status: response.status, data, duration, headers: response.headers, }; } /** * Log step execution with consistent formatting */ function logStep(stepNum: number, message: string): void { console.log(` Step ${stepNum}: ${message}`); } /** * Log API response details */ function logResponse(method: string, endpoint: string, status: number, duration: number): void { const statusIcon = status >= 200 && status < 300 ? '✅' : '❌'; console.log(` ${statusIcon} ${method} ${endpoint} - ${status} (${duration}ms)`); } /** * Log validation result */ function logValidation(passed: boolean, message: string): void { const icon = passed ? '✅' : '❌'; console.log(` ${icon} ${message}`); } /** * Sleep utility for rate limiting tests */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } // ============================================================ // E2E Scenarios // ============================================================ /** * Scenario 1: WordPress Server Recommendation → Detail Lookup * * Flow: * 1. POST /recommend with WordPress stack (nginx, php-fpm, mysql) * 2. Extract first recommended instance ID * 3. GET /instances with instance_id filter * 4. Validate specs meet requirements */ async function scenario1WordPress(context: TestContext, dryRun: boolean): Promise { console.log('\n▶️ Scenario 1: WordPress Server Recommendation → Detail Lookup'); if (dryRun) { console.log(' [DRY RUN] Would execute:'); console.log(' 1. POST /recommend with stack: nginx, php-fpm, mysql'); console.log(' 2. Extract instance_id from first recommendation'); console.log(' 3. GET /instances?instance_id={id}'); console.log(' 4. Validate memory >= 3072MB, vCPU >= 2'); return true; } try { // Step 1: Request recommendation logStep(1, 'Request WordPress server recommendation...'); const recommendResp = await apiRequest('/recommend', { method: 'POST', body: JSON.stringify({ stack: ['nginx', 'php-fpm', 'mysql'], scale: 'medium', }), }); logResponse('POST', '/recommend', recommendResp.status, recommendResp.duration); if (recommendResp.status !== 200) { console.log(` ❌ Expected 200, got ${recommendResp.status}`); return false; } const recommendData = recommendResp.data as { success: boolean; data?: { recommendations?: Array<{ instance: string; provider: string; price: { monthly: number }; region: string }>; }; }; if (!recommendData.success || !recommendData.data?.recommendations?.[0]) { console.log(' ❌ No recommendations returned'); return false; } const firstRec = recommendData.data.recommendations[0]; console.log(` Recommended: ${firstRec.instance} ($${firstRec.price.monthly}/mo) in ${firstRec.region}`); // Step 2: Extract instance identifier (we'll use provider + instance name for search) const instanceName = firstRec.instance; const provider = firstRec.provider; context.recommendedInstanceId = instanceName; // Step 3: Fetch instance details logStep(2, 'Fetch instance details...'); const detailsResp = await apiRequest( `/instances?provider=${encodeURIComponent(provider)}&limit=100`, { method: 'GET' } ); logResponse('GET', '/instances', detailsResp.status, detailsResp.duration); if (detailsResp.status !== 200) { console.log(` ❌ Expected 200, got ${detailsResp.status}`); return false; } const detailsData = detailsResp.data as { success: boolean; data?: { instances?: Array<{ instance_name: string; vcpu: number; memory_mb: number }>; }; }; const instance = detailsData.data?.instances?.find((i) => i.instance_name === instanceName); if (!instance) { console.log(` ❌ Instance ${instanceName} not found in details`); return false; } // Step 4: Validate specs logStep(3, 'Validate specs meet requirements...'); const memoryOk = instance.memory_mb >= 3072; // nginx 256 + php-fpm 1024 + mysql 2048 + OS 768 = 4096 const vcpuOk = instance.vcpu >= 2; logValidation(memoryOk, `Memory: ${instance.memory_mb}MB >= 3072MB required`); logValidation(vcpuOk, `vCPU: ${instance.vcpu} >= 2 required`); const passed = memoryOk && vcpuOk; console.log(` ${passed ? '✅' : '❌'} Scenario ${passed ? 'PASSED' : 'FAILED'} (${recommendResp.duration + detailsResp.duration}ms)`); return passed; } catch (error) { console.log(` ❌ Scenario FAILED with error: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Scenario 2: Budget-Constrained Instance Search * * Flow: * 1. GET /instances?max_price=50&sort_by=price&order=asc * 2. Validate all results <= $50/month * 3. Validate price sorting is correct */ async function scenario2Budget(context: TestContext, dryRun: boolean): Promise { console.log('\n▶️ Scenario 2: Budget-Constrained Instance Search'); if (dryRun) { console.log(' [DRY RUN] Would execute:'); console.log(' 1. GET /instances?max_price=50&sort_by=price&order=asc'); console.log(' 2. Validate all monthly_price <= $50'); console.log(' 3. Validate ascending price order'); return true; } try { // Step 1: Search within budget logStep(1, 'Search instances under $50/month...'); const searchResp = await apiRequest('/instances?max_price=50&sort_by=price&order=asc&limit=20', { method: 'GET', }); logResponse('GET', '/instances', searchResp.status, searchResp.duration); if (searchResp.status !== 200) { console.log(` ❌ Expected 200, got ${searchResp.status}`); return false; } const searchData = searchResp.data as { success: boolean; data?: { instances?: Array<{ pricing: { monthly_price: number } }>; }; }; const instances = searchData.data?.instances || []; if (instances.length === 0) { console.log(' ⚠️ No instances returned'); return true; // Not a failure, just empty result } // Step 2: Validate budget constraint logStep(2, 'Validate all prices within budget...'); const withinBudget = instances.every((i) => i.pricing.monthly_price <= 50); logValidation(withinBudget, `All ${instances.length} instances <= $50/month`); // Step 3: Validate sorting logStep(3, 'Validate price sorting (ascending)...'); let sortedCorrectly = true; for (let i = 1; i < instances.length; i++) { if (instances[i].pricing.monthly_price < instances[i - 1].pricing.monthly_price) { sortedCorrectly = false; break; } } logValidation(sortedCorrectly, `Prices sorted in ascending order`); const passed = withinBudget && sortedCorrectly; console.log(` ${passed ? '✅' : '❌'} Scenario ${passed ? 'PASSED' : 'FAILED'} (${searchResp.duration}ms)`); return passed; } catch (error) { console.log(` ❌ Scenario FAILED with error: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Scenario 3: Cross-Region Price Comparison * * Flow: * 1. GET /instances?region=ap-northeast-1 (Tokyo) * 2. GET /instances?region=ap-northeast-2 (Seoul) * 3. Compare average prices and instance counts */ async function scenario3RegionCompare(context: TestContext, dryRun: boolean): Promise { console.log('\n▶️ Scenario 3: Cross-Region Price Comparison (Tokyo vs Seoul)'); if (dryRun) { console.log(' [DRY RUN] Would execute:'); console.log(' 1. GET /instances?region=ap-northeast-1 (Tokyo)'); console.log(' 2. GET /instances?region=ap-northeast-2 (Seoul)'); console.log(' 3. Calculate average prices and compare'); return true; } try { // Step 1: Fetch Tokyo instances logStep(1, 'Fetch Tokyo (ap-northeast-1) instances...'); const tokyoResp = await apiRequest('/instances?region=ap-northeast-1&limit=100', { method: 'GET', }); logResponse('GET', '/instances?region=ap-northeast-1', tokyoResp.status, tokyoResp.duration); if (tokyoResp.status !== 200) { console.log(` ❌ Expected 200, got ${tokyoResp.status}`); return false; } const tokyoData = tokyoResp.data as { success: boolean; data?: { instances?: Array<{ pricing: { monthly_price: number } }>; pagination?: { total: number }; }; }; const tokyoInstances = tokyoData.data?.instances || []; context.tokyoInstances = tokyoData.data?.pagination?.total || tokyoInstances.length; // Step 2: Fetch Seoul instances logStep(2, 'Fetch Seoul (ap-northeast-2) instances...'); const seoulResp = await apiRequest('/instances?region=ap-northeast-2&limit=100', { method: 'GET', }); logResponse('GET', '/instances?region=ap-northeast-2', seoulResp.status, seoulResp.duration); if (seoulResp.status !== 200) { console.log(` ❌ Expected 200, got ${seoulResp.status}`); return false; } const seoulData = seoulResp.data as { success: boolean; data?: { instances?: Array<{ pricing: { monthly_price: number } }>; pagination?: { total: number }; }; }; const seoulInstances = seoulData.data?.instances || []; context.seoulInstances = seoulData.data?.pagination?.total || seoulInstances.length; // Step 3: Compare results logStep(3, 'Compare regions...'); const tokyoAvg = tokyoInstances.length > 0 ? tokyoInstances.reduce((sum, i) => sum + i.pricing.monthly_price, 0) / tokyoInstances.length : 0; const seoulAvg = seoulInstances.length > 0 ? seoulInstances.reduce((sum, i) => sum + i.pricing.monthly_price, 0) / seoulInstances.length : 0; console.log(` Tokyo: ${tokyoInstances.length} instances, avg $${tokyoAvg.toFixed(2)}/month`); console.log(` Seoul: ${seoulInstances.length} instances, avg $${seoulAvg.toFixed(2)}/month`); if (tokyoAvg > 0 && seoulAvg > 0) { const diff = ((tokyoAvg - seoulAvg) / seoulAvg) * 100; console.log(` Price difference: ${diff > 0 ? '+' : ''}${diff.toFixed(1)}%`); } const passed = tokyoInstances.length > 0 || seoulInstances.length > 0; // At least one region has data console.log(` ${passed ? '✅' : '❌'} Scenario ${passed ? 'PASSED' : 'FAILED'} (${tokyoResp.duration + seoulResp.duration}ms)`); return passed; } catch (error) { console.log(` ❌ Scenario FAILED with error: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Scenario 4: Provider Sync and Data Verification * * Flow: * 1. POST /sync with provider: linode * 2. GET /health to check sync_status * 3. GET /instances?provider=linode to verify data exists */ async function scenario4ProviderSync(context: TestContext, dryRun: boolean): Promise { console.log('\n▶️ Scenario 4: Provider Sync and Data Verification'); if (dryRun) { console.log(' [DRY RUN] Would execute:'); console.log(' 1. POST /sync with providers: ["linode"]'); console.log(' 2. GET /health to verify sync_status'); console.log(' 3. GET /instances?provider=linode to confirm data'); return true; } try { // Step 1: Trigger sync logStep(1, 'Trigger Linode data sync...'); const syncResp = await apiRequest('/sync', { method: 'POST', body: JSON.stringify({ providers: ['linode'], }), }); logResponse('POST', '/sync', syncResp.status, syncResp.duration); if (syncResp.status !== 200) { console.log(` ⚠️ Sync returned ${syncResp.status}, continuing...`); } const syncData = syncResp.data as { success: boolean; data?: { providers?: Array<{ provider: string; success: boolean; instances_synced: number }>; }; }; const linodeSync = syncData.data?.providers?.find((p) => p.provider === 'linode'); if (linodeSync) { console.log(` Synced: ${linodeSync.instances_synced} instances`); } // Step 2: Check health logStep(2, 'Verify sync status via /health...'); const healthResp = await apiRequest('/health', { method: 'GET' }); logResponse('GET', '/health', healthResp.status, healthResp.duration); if (healthResp.status !== 200 && healthResp.status !== 503) { console.log(` ❌ Unexpected status ${healthResp.status}`); return false; } const healthData = healthResp.data as { status: string; components?: { providers?: Array<{ name: string; sync_status: string; instances_count: number }>; }; }; const linodeHealth = healthData.components?.providers?.find((p) => p.name === 'linode'); if (linodeHealth) { console.log(` Status: ${linodeHealth.sync_status}, Instances: ${linodeHealth.instances_count}`); } // Step 3: Verify data exists logStep(3, 'Verify Linode instances exist...'); const instancesResp = await apiRequest('/instances?provider=linode&limit=10', { method: 'GET' }); logResponse('GET', '/instances?provider=linode', instancesResp.status, instancesResp.duration); if (instancesResp.status !== 200) { console.log(` ❌ Expected 200, got ${instancesResp.status}`); return false; } const instancesData = instancesResp.data as { success: boolean; data?: { instances?: unknown[]; pagination?: { total: number }; }; }; const totalInstances = instancesData.data?.pagination?.total || 0; context.linodeInstanceCount = totalInstances; const hasData = totalInstances > 0; logValidation(hasData, `Found ${totalInstances} Linode instances`); const passed = hasData; console.log(` ${passed ? '✅' : '❌'} Scenario ${passed ? 'PASSED' : 'FAILED'} (${syncResp.duration + healthResp.duration + instancesResp.duration}ms)`); return passed; } catch (error) { console.log(` ❌ Scenario FAILED with error: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Scenario 5: Rate Limiting Test * * Flow: * 1. Send 10 rapid requests to /instances * 2. Check for 429 Too Many Requests * 3. Verify Retry-After header */ async function scenario5RateLimit(context: TestContext, dryRun: boolean): Promise { console.log('\n▶️ Scenario 5: Rate Limiting Test'); if (dryRun) { console.log(' [DRY RUN] Would execute:'); console.log(' 1. Send 10 rapid requests to /instances'); console.log(' 2. Check for 429 status code'); console.log(' 3. Verify Retry-After header presence'); return true; } try { logStep(1, 'Send 10 rapid requests...'); const requests: Promise<{ status: number; data: unknown; duration: number; headers: Headers }>[] = []; for (let i = 0; i < 10; i++) { requests.push(apiRequest('/instances?limit=1', { method: 'GET' })); } const responses = await Promise.all(requests); const statuses = responses.map((r) => r.status); const has429 = statuses.includes(429); console.log(` Received statuses: ${statuses.join(', ')}`); logStep(2, 'Check for rate limiting...'); if (has429) { const rateLimitResp = responses.find((r) => r.status === 429); const retryAfter = rateLimitResp?.headers.get('Retry-After'); logValidation(true, `Rate limit triggered (429)`); logValidation(!!retryAfter, `Retry-After header: ${retryAfter || 'missing'}`); console.log(` ✅ Scenario PASSED - Rate limiting is working`); return true; } else { console.log(' ℹ️ No 429 responses (rate limit not triggered yet)'); console.log(` ✅ Scenario PASSED - All requests succeeded (limit not reached)`); return true; // Not a failure, just means we didn't hit the limit } } catch (error) { console.log(` ❌ Scenario FAILED with error: ${error instanceof Error ? error.message : String(error)}`); return false; } } // ============================================================ // Main Execution // ============================================================ interface ScenarioFunction { (context: TestContext, dryRun: boolean): Promise; } const scenarios: Record = { wordpress: scenario1WordPress, budget: scenario2Budget, region: scenario3RegionCompare, sync: scenario4ProviderSync, ratelimit: scenario5RateLimit, }; async function main(): Promise { const args = process.argv.slice(2); const scenarioFlag = args.findIndex((arg) => arg === '--scenario'); const dryRun = args.includes('--dry-run'); let selectedScenarios: [string, ScenarioFunction][]; if (scenarioFlag !== -1 && args[scenarioFlag + 1]) { const scenarioName = args[scenarioFlag + 1]; const scenarioFn = scenarios[scenarioName]; if (!scenarioFn) { console.error(`❌ Unknown scenario: ${scenarioName}`); console.log('\nAvailable scenarios:'); Object.keys(scenarios).forEach((name) => console.log(` - ${name}`)); process.exit(1); } selectedScenarios = [[scenarioName, scenarioFn]]; } else { selectedScenarios = Object.entries(scenarios); } console.log('🎬 E2E Scenario Tester'); console.log('================================'); console.log(`API: ${API_URL}`); if (dryRun) { console.log('Mode: DRY RUN (no actual requests)'); } console.log(''); const context: TestContext = {}; const results: Record = {}; const startTime = Date.now(); for (const [name, fn] of selectedScenarios) { try { results[name] = await fn(context, dryRun); // Add delay between scenarios to avoid rate limiting if (!dryRun && selectedScenarios.length > 1) { await sleep(1000); } } catch (error) { console.error(`\n❌ Scenario ${name} crashed:`, error); results[name] = false; } } const totalDuration = Date.now() - startTime; // Final report console.log('\n================================'); console.log('📊 E2E Report'); console.log(` Scenarios: ${selectedScenarios.length}`); console.log(` Passed: ${Object.values(results).filter((r) => r).length} ✅`); console.log(` Failed: ${Object.values(results).filter((r) => !r).length} ❌`); console.log(` Total Duration: ${(totalDuration / 1000).toFixed(1)}s`); if (Object.values(results).some((r) => !r)) { console.log('\n❌ Some scenarios failed'); process.exit(1); } else { console.log('\n✅ All scenarios passed'); process.exit(0); } } main().catch((error) => { console.error('💥 Fatal error:', error); process.exit(1); });