Cesar Mendivil 8b458a3ddf feat: add initial LiveKit Meet integration with utility scripts, configs, and core components
- Add Next.js app structure with base configs, linting, and formatting
- Implement LiveKit Meet page, types, and utility functions
- Add Docker, Compose, and deployment scripts for backend and token server
- Provide E2E and smoke test scaffolding with Puppeteer and Playwright helpers
- Include CSS modules and global styles for UI
- Add postMessage and studio integration utilities
- Update package.json with dependencies and scripts for development and testing
2025-11-20 12:50:38 -07:00

364 lines
17 KiB
JavaScript

#!/usr/bin/env node
// Local E2E runner for Broadcast Panel using Puppeteer
// Usage examples:
// node e2e/run_local_e2e.js
// TOKEN_SERVER=http://localhost:4000 BROADCAST_URL=http://localhost:5175 node e2e/run_local_e2e.js --show --ws http://localhost:9222
import puppeteer from 'puppeteer';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// --- CLI / ENV parsing ---
const argv = process.argv.slice(2);
function hasFlag(name) { return argv.includes(name); }
function getArgValue(name) {
const idx = argv.indexOf(name);
if (idx >= 0 && argv[idx+1]) return argv[idx+1];
return null;
}
const BACKEND = process.env.TOKEN_SERVER || process.env.BACKEND || 'http://localhost:4000';
const BROADCAST = process.env.BROADCAST_URL || 'http://localhost:5175';
const OUT_DIR = process.env.OUT_DIR || path.resolve(__dirname, 'out');
let REMOTE_WS = process.env.REMOTE_WS || getArgValue('--ws') || process.env.BROWSERLESS_WS || process.env.REMOTE_WSE;
const SHOW = process.env.SHOW === '1' || hasFlag('--show') || hasFlag('-s') || !!process.env.VISUAL;
const CHROME_PATH = process.env.CHROME_PATH || null;
fs.mkdirSync(OUT_DIR, { recursive: true });
function log(...args) {
console.log(...args);
try { fs.appendFileSync(path.join(OUT_DIR, 'run_local_e2e.log'), args.join(' ') + '\n'); } catch (e) {}
}
log('Local E2E: BACKEND=', BACKEND, 'BROADCAST=', BROADCAST, 'REMOTE_WS=', REMOTE_WS, 'SHOW=', SHOW);
// Use global fetch available on Node 18+
const fetchFn = global.fetch ? global.fetch.bind(global) : (...a) => import('node-fetch').then(m=>m.default(...a));
async function checkBackend(url) {
try { const root = await fetchFn(url, { method: 'GET' }); log('[check] GET', url, 'status', root.status, 'content-type', root.headers.get('content-type')); } catch (e) { log('[check] GET root failed for', url, String(e)); }
try { const health = await fetchFn(url.replace(/\/$/, '') + '/health', { method: 'GET' }); log('[check] GET /health', url, 'status', health.status); } catch (e) { }
}
async function resolveRemoteWSEndpoint(raw) {
if (!raw) return null;
raw = String(raw).trim();
// if starts with ws or wss, return as-is
if (raw.startsWith('ws://') || raw.startsWith('wss://')) return raw;
// if it's numeric (port) assume localhost:port
if (/^\d+$/.test(raw)) raw = `http://localhost:${raw}`;
// if starts with http, try to fetch /json/version and /json/list
if (!raw.startsWith('http://') && !raw.startsWith('https://')) raw = `http://${raw}`;
try {
const ver = await fetchFn(raw.replace(/\/$/, '') + '/json/version');
if (ver && ver.ok) {
const j = await ver.json();
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
}
} catch (e) { /* ignore */ }
try {
const list = await fetchFn(raw.replace(/\/$/, '') + '/json/list');
if (list && list.ok) {
const arr = await list.json();
if (Array.isArray(arr) && arr.length) {
// prefer first page's webSocketDebuggerUrl
if (arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl;
}
}
} catch (e) { /* ignore */ }
// try /json
try {
const j = await fetchFn(raw.replace(/\/$/, '') + '/json');
if (j && j.ok) {
const arr = await j.json();
if (Array.isArray(arr) && arr.length && arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl;
}
} catch (e) { }
return null;
}
function isExecutable(p) { try { fs.accessSync(p, fs.constants.X_OK); return true; } catch (e) { return false; } }
function resolveChromeExecutable() {
const candidates = [];
if (CHROME_PATH) candidates.push(CHROME_PATH);
// Prefer system-installed chrome first
candidates.push('/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium', '/snap/bin/chromium');
// packaged chromium in project (previously detected)
candidates.push(path.resolve(__dirname, 'chrome', 'linux-144.0.7531.0', 'chrome-linux64'));
for (const c of candidates) {
if (!c) continue;
try {
if (fs.existsSync(c)) {
if (isExecutable(c)) return c;
// try to chmod
try { fs.chmodSync(c, 0o755); if (isExecutable(c)) return c; } catch (e) { log('Could not chmod candidate', c, String(e)); }
log('Candidate exists but not executable (skipping):', c);
}
} catch (e) { }
}
// try puppeteer.executablePath() as last resort if it's valid and executable
try {
const ep = typeof puppeteer.executablePath === 'function' ? puppeteer.executablePath() : puppeteer.executablePath;
if (ep && fs.existsSync(ep)) {
if (isExecutable(ep)) return ep;
try { fs.chmodSync(ep, 0o755); if (isExecutable(ep)) return ep; } catch (e) { log('puppeteer.executablePath exists but not executable and chmod failed', ep, String(e)); }
}
} catch (e) { }
return null;
}
(async () => {
await checkBackend(BACKEND);
if (BACKEND.includes('localhost')) await checkBackend(BACKEND.replace('localhost', '127.0.0.1'));
// 1) create session on backend
let session = null;
try {
const res = await fetchFn(`${BACKEND.replace(/\/$/, '')}/api/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room: 'local-e2e-room', username: 'local-runner', ttl: 300 }) });
const text = await res.text(); try { session = JSON.parse(text); } catch (e) { session = null; }
log('POST /api/session status', res.status);
log('body start', String(text).slice(0, 400));
if (!res.ok) { log('[error] backend POST returned non-OK. Full body:\n', text); throw new Error('Failed create session (non-OK)'); }
if (!session) { log('[error] backend POST returned non-JSON body, aborting.'); throw new Error('Failed create session (no JSON)'); }
} catch (err) {
console.error('Failed to create session on backend:', err && err.message ? err.message : String(err));
console.error('Hint: asegúrate de que el backend API esté corriendo en', BACKEND, 'y responde /api/session');
process.exit(1);
}
const token = session && session.token ? session.token : null;
log('Session id', session && session.id, 'token?', !!token);
if (!token && session && session.id) {
try {
log('POST did not include token, attempting GET /api/session/:id');
const getResp = await fetchFn(`${BACKEND.replace(/\/$/, '')}/api/session/${encodeURIComponent(session.id)}`);
const getText = await getResp.text(); let getJson = null; try { getJson = JSON.parse(getText); } catch (e) { getJson = null; }
log('GET /api/session/:id status', getResp.status, 'body start', String(getText).slice(0, 400));
if (getJson && getJson.token) { session.token = getJson.token; log('Obtained token from GET /api/session/:id (length', session.token.length, ')'); } else { log('GET /api/session/:id did not return token'); }
} catch (e) { log('Error while GET /api/session/:id', String(e)); }
}
// 2) connect or launch puppeteer
let browser;
try {
let connectWSEndpoint = null;
if (REMOTE_WS) {
log('Raw REMOTE_WS provided:', REMOTE_WS);
// try to resolve to webSocketDebuggerUrl when necessary
connectWSEndpoint = await resolveRemoteWSEndpoint(REMOTE_WS).catch(e => null);
if (!connectWSEndpoint) {
// maybe REMOTE_WS was like 'localhost:9222' without http
connectWSEndpoint = await resolveRemoteWSEndpoint(REMOTE_WS).catch(e => null);
}
}
if (connectWSEndpoint) {
log('Connecting to remote browser WS endpoint:', connectWSEndpoint);
browser = await puppeteer.connect({ browserWSEndpoint: connectWSEndpoint });
} else if (REMOTE_WS && REMOTE_WS.startsWith('ws://')) {
log('Connecting to remote browser WS (as-is):', REMOTE_WS);
browser = await puppeteer.connect({ browserWSEndpoint: REMOTE_WS });
} else {
const launchOptions = { headless: !SHOW, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] };
let resolved = resolveChromeExecutable();
if (resolved) { launchOptions.executablePath = resolved; log('Resolved executablePath for Chrome/Chromium:', resolved); } else { log('No explicit chrome executable resolved; using puppeteer default (may download bundled browser)'); }
log('Launching local browser, headless=', launchOptions.headless, 'exe=', launchOptions.executablePath || '(puppeteer default)');
try {
browser = await puppeteer.launch(launchOptions);
} catch (launchErr) {
log('Initial launch failed:', launchErr && launchErr.message ? launchErr.message : String(launchErr));
// If EACCES on a resolved executable, try to chmod and retry
if (launchOptions.executablePath && launchErr && launchErr.message && launchErr.message.includes('EACCES')) {
try { fs.chmodSync(launchOptions.executablePath, 0o755); log('Chmod applied to', launchOptions.executablePath); browser = await puppeteer.launch(launchOptions); }
catch (e) { log('Retry after chmod failed:', String(e)); }
}
// final fallback: try system chrome paths explicitly
if (!browser) {
const fallbacks = ['/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium'];
for (const f of fallbacks) {
try {
if (fs.existsSync(f)) {
try { fs.chmodSync(f, 0o755); } catch (e) {}
launchOptions.executablePath = f; log('Retrying launch with fallback exe', f);
browser = await puppeteer.launch(launchOptions);
if (browser) break;
}
} catch (e) { log('Fallback launch attempt failed for', f, String(e)); }
}
}
if (!browser) throw launchErr;
}
}
} catch (err) {
console.error('Failed to launch/connect puppeteer:', err && err.message ? err.message : String(err));
console.error('Resolved CHROME_PATH:', CHROME_PATH || '(not set)');
process.exit(2);
}
try {
const page = await browser.newPage();
page.setDefaultTimeout(20000);
page.on('console', msg => log('[BROWSER]', msg.type(), msg.text()));
page.on('pageerror', err => log('[PAGEERROR]', err && err.stack ? err.stack : String(err)));
async function sleep(ms) { return new Promise(res => setTimeout(res, ms)); }
// click element by fuzzy text
async function clickByText(words, tag = '*') {
for (const w of words) {
const clicked = await page.evaluate((w, tag) => {
try {
const els = Array.from(document.querySelectorAll(tag));
for (const el of els) {
const txt = (el.innerText || '').trim();
if (txt && txt.indexOf(w) !== -1) { el.scrollIntoView({ block: 'center', inline: 'center' }); el.click(); return true; }
}
} catch (e) { }
return false;
}, w, tag);
if (clicked) { log('Clicked', w); return true; }
}
return false;
}
async function setInputValue(selector, value) {
try {
if (!selector) {
const anyHandle = await page.$('input[type="text"], input, textarea');
if (!anyHandle) return false;
await anyHandle.focus();
await page.keyboard.down('Control'); await page.keyboard.press('A'); await page.keyboard.up('Control');
await page.keyboard.type(value, { delay: 80 });
return true;
}
await page.focus(selector);
await page.evaluate((s) => { const el = document.querySelector(s); if (el) el.value = ''; }, selector);
await page.type(selector, value, { delay: 80 });
return true;
} catch (e) { log('setInputValue failed', String(e)); return false; }
}
// 3) Navigate to broadcast panel or studioUrl
// If backend returned a studioUrl, open it directly so the StudioPortal can receive the token
const targetUrl = (session && session.studioUrl) ? session.studioUrl : BROADCAST;
log('Navigating to target URL (studio or broadcast):', targetUrl);
await page.goto(targetUrl, { waitUntil: 'networkidle2' });
await sleep(800);
// 4) Click create transmission button
const createCandidates = ['Nueva transmisión', 'Crear transmisión', 'Crear transmisión en vivo', 'Nueva transmisión en vivo', 'Nueva emisión', 'Crear', 'Transmitir', 'Nueva'];
let opened = await clickByText(createCandidates, 'button');
if (!opened) opened = await clickByText(createCandidates, 'a');
if (!opened) opened = await clickByText(createCandidates, 'div');
if (!opened) log('Warning: create button not found automatically');
await sleep(600);
// 5) If modal shows, try to click 'Omitir' or 'Skip' or close it
const skipCandidates = ['Omitir', 'Saltar', 'Skip', 'Cerrar', 'Cerrar modal', 'No, gracias'];
const skipped = await clickByText(skipCandidates, 'button') || await clickByText(skipCandidates, 'a');
if (skipped) { log('Skipped modal'); await sleep(400); }
// 6) Find text input for title and set to 'Transmitir'
await setInputValue(null, 'Transmitir');
await sleep(400);
// 7) Click 'Empezar ahora' / 'Comenzar' / 'Empezar'
const startCandidates = ['Empezar ahora', 'Comenzar ahora', 'Empezar', 'Iniciar ahora', 'Comenzar', 'Empezar transmisión'];
let started = await clickByText(startCandidates, 'button');
if (!started) started = await clickByText(startCandidates, 'a');
if (!started) log('Warning: start button not found automatically');
await sleep(1200);
// 8) Click 'Entrar al estudio' or similar
const enterCandidates = ['Entrar al estudio', 'Entrar', 'Conectar', 'Ir al estudio', 'Abrir estudio', 'Entrar al estudio ahora'];
let entered = await clickByText(enterCandidates, 'button');
if (!entered) entered = await clickByText(enterCandidates, 'a');
if (!entered) log('Warning: enter to studio button not found');
await sleep(1500);
// 9) If token exists, postMessage it to the page (StudioPortal listens for LIVEKIT_TOKEN)
try {
if (session && session.token) {
log('Posting token to page via postMessage (token length', session.token.length, ')');
await page.evaluate((tk, srv) => {
try { window.postMessage({ type: 'LIVEKIT_TOKEN', token: tk, room: 'local-e2e-room', serverUrl: srv }, window.location.origin); } catch (e) { /* ignore */ }
}, session.token, session.url || session.serverUrl || session.livekitUrl || '');
await sleep(800);
// Wait for StudioPortal to indicate token received or connected
let connected = false;
try {
const start = Date.now();
while (Date.now() - start < 10000) {
const text = await page.evaluate(() => (document.body && document.body.innerText) || '');
if (text.indexOf('Token recibido') !== -1 || text.indexOf('Conectado') !== -1 || text.indexOf('Conectando') !== -1) { connected = true; break; }
await new Promise(r => setTimeout(r, 400));
}
} catch (e) { }
if (!connected) {
log('Auto-connect not detected, attempting to click Connect button');
// find buttons with class .btn-small and innerText includes 'Conectar'
try {
const clicked = await page.evaluate(() => {
const els = Array.from(document.querySelectorAll('button.btn-small'));
for (const el of els) {
const t = (el.innerText || '').trim();
if (t && t.indexOf('Conectar') !== -1) { (el as any).click(); return true; }
}
// fallback: look for any button with text 'Conectar'
const any = Array.from(document.querySelectorAll('button'));
for (const b of any) {
const t = (b.innerText || '').trim();
if (t && t.indexOf('Conectar') !== -1) { (b as any).click(); return true; }
}
return false;
});
log('Clicked Connect button?', !!clicked);
if (clicked) await sleep(1200);
} catch (e) { log('Click Connect failed', String(e)); }
}
}
} catch (e) { log('postMessage/open studio failed', String(e)); }
// 10) Wait a bit and try to detect indicators of token/connection
const indicators = ['Token recibido', 'Conectado', 'Conectando', 'LiveKit', 'livekit-js', 'Connected', 'Token'];
let saw = false;
try {
const start = Date.now();
while (Date.now() - start < 15000) {
const found = await page.evaluate((indicators) => { const text = document.body && document.body.innerText || ''; return indicators.some(i => text.indexOf(i) !== -1); }, indicators);
if (found) { saw = true; break; }
await sleep(500);
}
} catch (e) { }
log('Studio token/connection indicator found?', !!saw);
const screenshotFile = path.join(OUT_DIR, `local_e2e_${Date.now()}.png`);
await page.screenshot({ path: screenshotFile, fullPage: false });
log('Saved screenshot to', screenshotFile);
if (!REMOTE_WS && SHOW) { log('Leaving browser open for manual inspection (SHOW=true)'); process.exit(0); }
await browser.close();
log('Local E2E finished OK');
process.exit(0);
} catch (err) {
console.error('E2E error', err && err.stack ? err.stack : err);
try { await browser && browser.close(); } catch (e) { }
process.exit(3);
}
})();