- 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.
300 lines
11 KiB
JavaScript
300 lines
11 KiB
JavaScript
#!/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);
|
||
})();
|