- 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
364 lines
17 KiB
JavaScript
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);
|
|
}
|
|
})();
|