#!/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); } })();