restreamer-ui-v2/scripts/test-whip-obs-e2e.js
Cesar Mendivil 00e98a19b3 feat: add InternalWHIP component and associated tests
- Implemented the InternalWHIP component for managing WHIP server configurations.
- Added functionality to load live WHIP state from Core and handle OBS URL generation.
- Included polling for active streams and notifying parent components of state changes.
- Created comprehensive tests for the InternalWHIP component covering various scenarios including fallback mechanisms and state changes.

test: add integration tests for WHIP source component

- Developed end-to-end tests for the InternalWHIP component to verify its behavior under different configurations.
- Ensured that the component correctly handles the loading of WHIP state, displays appropriate messages, and emits the correct onChange events.

test: add Settings WHIP configuration tests

- Implemented tests for the WHIP settings tab to validate loading and saving of WHIP configurations.
- Verified that the correct values are sent back to the Core when the user saves changes.
- Ensured that the UI reflects the current state of the WHIP configuration after Core restarts or changes.
2026-03-14 12:27:53 -07:00

300 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* E2E: Valida que la URL WHIP generada para OBS funcione correctamente.
*
* Pruebas:
* 1. Crear Ingress en LiveKit (Twirp HTTP API, igual que el browser)
* 2. Construir la URL pública (djmaster.../w/<stream_key>)
* 3. OPTIONS al endpoint WHIP — OBS hace esto primero para verificar compatibilidad
* 4. POST al endpoint WHIP — simula el inicio de sesión de OBS
* 5. Verificar que el proxy Caddy /w/* reenvía al livekit-ingress real
*
* Uso:
* node scripts/test-whip-obs-e2e.js
*/
'use strict';
const http = require('http');
const https = require('https');
const crypto = require('crypto');
// ── Config ─────────────────────────────────────────────────────────────────
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_BASE_URL = process.env.UI_BASE_URL || 'https://djmaster.nextream.sytes.net';
const ROOM = 'e2e-obs-' + Date.now();
// ── Helpers ────────────────────────────────────────────────────────────────
let failures = 0;
const ok = (msg) => console.log(`${msg}`);
const fail = (msg) => { console.error(`${msg}`); failures++; };
const info = (msg) => console.log(` ${msg}`);
const section = (t) => console.log(`\n━━━━ ${t} ━━━━`);
function request(url, { method = 'GET', headers = {}, body } = {}) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const lib = u.protocol === 'https:' ? https : http;
const raw = body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined;
const opts = {
hostname: u.hostname,
port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + (u.search || ''),
method,
headers: {
...headers,
...(raw ? { 'Content-Length': Buffer.byteLength(raw) } : {}),
},
rejectUnauthorized: false,
timeout: 10000,
};
const req = lib.request(opts, (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);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
if (raw) req.write(raw);
req.end();
});
}
// Genera JWT HS256 con node:crypto (solo para el script Node — no para el browser)
function buildToken() {
const b64url = (o) =>
Buffer.from(JSON.stringify(o))
.toString('base64')
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
const now = Math.floor(Date.now() / 1000);
const header = b64url({ alg: 'HS256', typ: 'JWT' });
const payload = b64url({
iss: LK_API_KEY, sub: 'e2e-test',
iat: now, exp: now + 3600,
video: { roomCreate: true, roomList: true, roomAdmin: true, ingressAdmin: true },
});
const sig = crypto
.createHmac('sha256', LK_API_SECRET)
.update(`${header}.${payload}`)
.digest('base64')
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
return `${header}.${payload}.${sig}`;
}
// ── Test 1: Crear Ingress via Twirp (mismo path que el browser) ────────────
async function testCreateIngress() {
section('Test 1 — Crear Ingress (Twirp HTTP API)');
info(`LiveKit Server: ${LK_HTTP_URL}`);
info(`Room: ${ROOM}`);
const token = buildToken();
let ingress;
try {
const r = await request(`${LK_HTTP_URL}/twirp/livekit.Ingress/CreateIngress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: { inputType: 1, name: 'e2e-obs', roomName: ROOM,
participantIdentity: 'obs-whip', participantName: 'OBS' },
});
if (r.status !== 200) {
fail(`Twirp CreateIngress → HTTP ${r.status}: ${r.body.slice(0, 200)}`);
if (r.status === 401) info('→ JWT inválido o credenciales incorrectas');
return null;
}
ingress = JSON.parse(r.body);
ok(`Ingress creado → ingress_id: ${ingress.ingress_id}`);
ok(`stream_key: ${ingress.stream_key}`);
ok(`url interna reportada: ${ingress.url}`);
} catch (err) {
fail(`Twirp request falló: ${err.message}`);
return null;
}
return ingress;
}
// ── Test 2: Construir URL pública ──────────────────────────────────────────
function testBuildUrl(ingress) {
section('Test 2 — Construir URL pública WHIP');
if (!ingress) { fail('Sin ingress (test anterior falló)'); return null; }
const whipUrl = `${UI_BASE_URL}/w/${ingress.stream_key}`;
info(`UI_BASE_URL: ${UI_BASE_URL}`);
info(`stream_key: ${ingress.stream_key}`);
ok(`WHIP URL → ${whipUrl}`);
try {
const u = new URL(whipUrl);
ok(`URL válida (protocol: ${u.protocol}, host: ${u.hostname})`);
if (whipUrl.includes('192.168.') || whipUrl.includes('localhost')) {
fail('La URL contiene IP/host privado — OBS externo NO puede conectar');
} else {
ok('URL no contiene IPs privadas');
}
if (u.pathname.endsWith(ingress.stream_key)) {
ok(`Pathname termina en stream_key (/w/${ingress.stream_key})`);
} else {
fail(`Pathname inesperado: ${u.pathname}`);
}
} catch {
fail(`URL malformada: ${whipUrl}`);
return null;
}
return whipUrl;
}
// ── Test 3: OPTIONS al endpoint WHIP (OBS discovery) ──────────────────────
async function testWhipOptions(whipUrl) {
section('Test 3 — OPTIONS al endpoint WHIP (OBS discovery)');
if (!whipUrl) { fail('Sin URL (test anterior falló)'); return; }
info(`URL: ${whipUrl}`);
try {
const r = await request(whipUrl, {
method: 'OPTIONS',
headers: {
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'content-type',
Origin: UI_BASE_URL,
},
});
info(`HTTP ${r.status}`);
info(`Allow: ${r.headers['allow'] || r.headers['Access-Control-Allow-Methods'] || '(no Allow header)'}`);
info(`Content-Type: ${r.headers['content-type'] || '(none)'}`);
// livekit-ingress responde 200 o 204 a OPTIONS
if ([200, 204, 405].includes(r.status)) {
ok(`OPTIONS respondió ${r.status} — el proxy Caddy /w/* está activo`);
} else if (r.status === 404) {
fail(`404 — Caddy no tiene el bloque "handle /w/*" o livekit-ingress no responde`);
} else if (r.status === 502 || r.status === 503) {
fail(`${r.status} — Caddy no puede alcanzar livekit-ingress (${r.body.slice(0,100)})`);
} else {
info(`Respuesta no estándar ${r.status} — puede ser normal si livekit-ingress no implementa OPTIONS`);
}
// CORS check (necesario si el browser llama directamente)
const acao = r.headers['access-control-allow-origin'];
if (acao) {
ok(`CORS: Access-Control-Allow-Origin: ${acao}`);
} else {
info('Sin header CORS (esperado si OBS no usa CORS)');
}
} catch (err) {
fail(`OPTIONS request falló: ${err.message}`);
if (err.message === 'timeout') {
info('→ El host no responde en 10s — ¿está Caddy corriendo? ¿el dominio resuelve?');
}
}
}
// ── Test 4: POST al endpoint WHIP (simula inicio de sesión de OBS) ─────────
async function testWhipPost(whipUrl) {
section('Test 4 — POST al endpoint WHIP (SDP offer simulado)');
if (!whipUrl) { fail('Sin URL (test anterior falló)'); return; }
info(`URL: ${whipUrl}`);
// SDP mínimo válido — suficiente para que livekit-ingress procese la petición
const sdpOffer = [
'v=0',
'o=- 0 0 IN IP4 127.0.0.1',
's=-',
't=0 0',
'a=group:BUNDLE 0',
'm=video 9 UDP/TLS/RTP/SAVPF 96',
'c=IN IP4 0.0.0.0',
'a=sendonly',
'a=rtpmap:96 H264/90000',
'a=mid:0',
].join('\r\n') + '\r\n';
try {
const r = await request(whipUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: sdpOffer,
});
info(`HTTP ${r.status}`);
info(`Content-Type: ${r.headers['content-type'] || '(none)'}`);
info(`Location: ${r.headers['location'] || '(none)'}`);
if (r.status === 201) {
ok('201 Created — OBS puede conectar correctamente ✅');
info(`SDP answer (primeros 200 chars): ${r.body.slice(0, 200)}`);
} else if (r.status === 422 || r.status === 400) {
// Error de SDP esperado con nuestro SDP mínimo — pero confirma que el endpoint existe
ok(`${r.status} — Endpoint WHIP alcanzado (SDP inválido esperado en e2e)`);
info('OBS enviará un SDP real completo — esto es normal en tests');
} else if (r.status === 404) {
fail('404 — La ruta /w/<stream_key> no existe en livekit-ingress');
info('→ ¿El stream_key es válido? ¿El Ingress fue creado con éxito?');
} else if (r.status === 405) {
fail('405 — El endpoint no acepta POST (Caddy file_server interviniendo?)');
} else if (r.status === 502 || r.status === 503) {
fail(`${r.status} — Caddy no alcanza livekit-ingress`);
info('→ Verifica LIVEKIT_INGRESS_HOST en docker-compose.yml');
info('→ Verifica que livekit-ingress esté corriendo en 192.168.1.20:8088');
} else {
info(`Respuesta ${r.status}: ${r.body.slice(0, 200)}`);
}
} catch (err) {
fail(`POST request falló: ${err.message}`);
}
}
// ── Test 5: Limpiar ingress de prueba ──────────────────────────────────────
async function cleanupIngress(ingressId) {
section('Cleanup — Eliminar Ingress de prueba');
if (!ingressId) { info('Sin ingress que limpiar'); return; }
const token = buildToken();
try {
const r = await request(`${LK_HTTP_URL}/twirp/livekit.Ingress/DeleteIngress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: { ingress_id: ingressId },
});
if (r.status === 200) {
ok(`Ingress ${ingressId} eliminado`);
} else {
info(`Delete respondió ${r.status} (no crítico)`);
}
} catch (err) {
info(`No se pudo eliminar: ${err.message}`);
}
}
// ── Main ───────────────────────────────────────────────────────────────────
(async () => {
console.log('╔════════════════════════════════════════════════════╗');
console.log('║ WHIP OBS End-to-End Test Suite ║');
console.log('╚════════════════════════════════════════════════════╝');
console.log(`\n LK Server: ${LK_HTTP_URL}`);
console.log(` UI Base: ${UI_BASE_URL}`);
console.log(` API Key: ${LK_API_KEY}`);
const ingress = await testCreateIngress();
const whipUrl = testBuildUrl(ingress);
await testWhipOptions(whipUrl);
await testWhipPost(whipUrl);
await cleanupIngress(ingress?.ingress_id);
console.log('\n' + '─'.repeat(54));
if (failures === 0) {
console.log(' ✅ Todos los tests pasaron');
} else {
console.log(`${failures} test(s) fallaron`);
}
console.log('─'.repeat(54) + '\n');
process.exit(failures > 0 ? 1 : 0);
})();