#!/usr/bin/env node /** * E2E: Valida la generación de URL WHIP via LiveKit Ingress. * * Ejecuta 3 pruebas en secuencia: * 1. Conectividad directa al LiveKit Server (HTTPS) * 2. IngressClient.createIngress() directo (sin pasar por la UI) * 3. POST /api/whip/info al servidor Node de la UI * * Uso: * node scripts/test-whip-e2e.js * node scripts/test-whip-e2e.js http://localhost:3000 # UI en otro host * * Sin argumentos usa las vars de entorno del docker-compose o sus defaults. */ 'use strict'; const http = require('http'); const https = require('https'); // ── Configuración (mirrors docker-compose.yml) ───────────────────────────── const LK_API_KEY = process.env.LIVEKIT_API_KEY || 'APIBTqTGxf9htMK'; const LK_API_SECRET = process.env.LIVEKIT_API_SECRET || '0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW'; const LK_WS_URL = process.env.LIVEKIT_WS_URL || 'wss://livekit-server.nextream.sytes.net'; const LK_HTTP_URL = LK_WS_URL.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://'); const UI_HOST = (process.argv[2] || 'http://localhost:3002').replace(/\/+$/, ''); const ROOM = 'e2e-whip-test-' + Date.now(); // ── Helpers ──────────────────────────────────────────────────────────────── let failures = 0; function ok(msg) { console.log(` ✓ ${msg}`); } function fail(msg) { console.error(` ✗ ${msg}`); failures++; } function section(title) { console.log(`\n━━ ${title} ━━`); } function httpRequest(url, { method = 'GET', body, headers = {} } = {}) { return new Promise((resolve, reject) => { const parsed = new URL(url); const lib = parsed.protocol === 'https:' ? https : http; const raw = body ? JSON.stringify(body) : undefined; const reqOpts = { hostname: parsed.hostname, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), path: parsed.pathname + (parsed.search || ''), method, headers: { ...headers, ...(raw ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(raw) } : {}), }, // Allow self-signed certs for local testing rejectUnauthorized: false, }; const req = lib.request(reqOpts, (res) => { let buf = ''; res.on('data', (c) => { buf += c; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: buf })); }); req.on('error', reject); if (raw) req.write(raw); req.end(); }); } // ── Test 1: Conectividad al LiveKit Server ───────────────────────────────── async function testLiveKitConnectivity() { section('Test 1: Conectividad a LiveKit Server'); console.log(` URL: ${LK_HTTP_URL}`); try { const r = await httpRequest(`${LK_HTTP_URL}/`, { method: 'GET' }); // LiveKit devuelve 404 o 405 en /, pero cualquier respuesta HTTP confirma conectividad if (r.status < 600) { ok(`LiveKit Server responde HTTP ${r.status} (conectividad OK)`); } else { fail(`Respuesta inesperada: ${r.status}`); } } catch (err) { fail(`No se puede conectar a ${LK_HTTP_URL}: ${err.message}`); console.log(' → Verifica que livekit-server.nextream.sytes.net sea accesible'); } } // ── Test 2: IngressClient directo (bypass UI) ────────────────────────────── async function testIngressClientDirect() { section('Test 2: IngressClient.createIngress() directo'); console.log(` API Key: ${LK_API_KEY}`); console.log(` LK Server: ${LK_HTTP_URL}`); console.log(` Room: ${ROOM}`); // Construir JWT manualmente usando mismo algoritmo que livekit-server-sdk // para aislar si el problema es de credenciales o de config del server. try { const { IngressClient, IngressInput } = require(require('path').resolve(__dirname, '../server/node_modules/livekit-server-sdk')); const client = new IngressClient(LK_HTTP_URL, LK_API_KEY, LK_API_SECRET); const ingress = await client.createIngress(IngressInput.WHIP_INPUT, { name: 'e2e-test', roomName: ROOM, participantIdentity: 'e2e-obs', participantName: 'E2E OBS', }); ok(`createIngress() OK → ingressId: ${ingress.ingressId}`); ok(`streamKey: ${ingress.streamKey}`); ok(`ingress.url (reportada por LiveKit): ${ingress.url}`); // Cleanup: intentar borrar el ingress creado try { await client.deleteIngress(ingress.ingressId); ok(`Ingress de prueba eliminado`); } catch (_) {} return ingress; } catch (err) { fail(`createIngress() falló: ${err.message}`); if (err.message.includes('401') || err.message.includes('Unauthorized') || err.message.includes('JWT')) { console.log('\n ── Diagnóstico JWT ───────────────────────────────────────'); console.log(' El error 401/JWT indica que las credenciales API no coinciden'); console.log(' con las que tiene configurado el LiveKit Server.\n'); console.log(' Verifica en tu livekit-server.yaml:'); console.log(' key_file: '); console.log(' o'); console.log(' keys:'); console.log(` ${LK_API_KEY}: `); console.log(`\n El secret configurado en la UI es: ${LK_API_SECRET}`); console.log(' Debe coincidir EXACTAMENTE con el que tiene livekit-server.'); } if (err.message.includes('not found') || err.message.includes('ENOTFOUND')) { console.log(' → El hostname no resuelve. Verifica DNS / VPN.'); } return null; } } // ── Test 3: POST /api/whip/info via Node server ──────────────────────────── async function testWhipInfoEndpoint() { section(`Test 3: POST ${UI_HOST}/api/whip/info`); try { const r = await httpRequest(`${UI_HOST}/api/whip/info`, { method: 'POST', body: { room: ROOM, identity: 'e2e-test', name: 'E2E Test' }, }); let data; try { data = JSON.parse(r.body); } catch { data = { _raw: r.body }; } if (r.status === 200) { ok(`HTTP 200`); ok(`whipUrl: ${data.whipUrl}`); ok(`streamKey: ${data.streamKey}`); ok(`ingressId: ${data.ingressId}`); // Validaciones de la URL try { const u = new URL(data.whipUrl); ok(`whipUrl es URL válida (protocol: ${u.protocol})`); if (data.whipUrl.includes('192.168.')) { fail(`whipUrl contiene IP privada — OBS externo no puede acceder`); } else { ok(`whipUrl no contiene IP privada`); } if (data.whipUrl.endsWith(data.streamKey)) { ok(`whipUrl termina en streamKey`); } else { fail(`whipUrl NO termina en streamKey (verifica la construcción)`); } } catch { fail(`whipUrl no es URL válida: ${data.whipUrl}`); } } else if (r.status === 404 || r.status === 405) { fail(`HTTP ${r.status} — la ruta /api/whip/info no existe en el servidor`); console.log(' → Verifica que el servidor Node (port 3002) tenga la ruta registrada'); console.log(' → Y que Caddy tenga el bloque "handle /api/whip/*"'); } else { fail(`HTTP ${r.status}: ${JSON.stringify(data)}`); } } catch (err) { fail(`Request falló: ${err.message}`); console.log(` → Verifica que el servidor esté corriendo en ${UI_HOST}`); } } // ── Test 4: Proxy Caddy /api/whip/* (si se pasa URL del UI en :3000) ─────── async function testCaddyProxy() { if (!process.argv[2]) return; // Solo si se especificó UI_HOST explícitamente section(`Test 4: Caddy proxy POST ${process.argv[2]}/api/whip/info`); try { const r = await httpRequest(`${process.argv[2]}/api/whip/info`, { method: 'POST', body: { room: ROOM + '-caddy', identity: 'e2e-caddy', name: 'E2E Caddy' }, }); const data = JSON.parse(r.body).catch?.() || JSON.parse(r.body); if (r.status === 200) { ok(`Caddy proxy OK → whipUrl: ${data.whipUrl}`); } else { fail(`Caddy proxy HTTP ${r.status}: ${r.body.slice(0, 200)}`); } } catch (err) { fail(`Caddy proxy error: ${err.message}`); } } // ── Main ─────────────────────────────────────────────────────────────────── (async () => { console.log('\n╔══════════════════════════════════════╗'); console.log('║ WHIP Ingress E2E Test Suite ║'); console.log('╚══════════════════════════════════════╝'); console.log(` Fecha: ${new Date().toISOString()}`); // Check livekit-server-sdk disponible (busca en server/node_modules) const sdkPath = require('path').resolve(__dirname, '../server/node_modules/livekit-server-sdk'); try { require(sdkPath); } catch { console.error('\n✗ livekit-server-sdk no encontrado en server/node_modules.'); console.error(' Ejecuta: cd server && npm install'); process.exit(1); } await testLiveKitConnectivity(); await testIngressClientDirect(); await testWhipInfoEndpoint(); await testCaddyProxy(); console.log('\n━━ Resultado final ━━'); if (failures === 0) { console.log(' ✅ TODOS los tests pasaron\n'); } else { console.log(` ❌ ${failures} test(s) fallaron — revisa los errores arriba\n`); process.exit(1); } })();