- 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
209 lines
10 KiB
JavaScript
209 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
// E2E test (CommonJS) with instrumentation: create session on backend-api, connect to browserless,
|
|
// open Broadcast Panel, automate UI (create transmission if needed), postMessage token and wait for Studio overlay.
|
|
// Instrumentation: capture page console, page errors, network requests/responses, trace, HTML snapshot, screenshots and logs.
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const fetch = require('node-fetch')
|
|
const puppeteer = require('puppeteer-core')
|
|
|
|
const ARTIFACT_DIR = '/tmp/avanzacast_e2e'
|
|
if (!fs.existsSync(ARTIFACT_DIR)) fs.mkdirSync(ARTIFACT_DIR, { recursive: true })
|
|
|
|
function now() { return new Date().toISOString().replace(/[:.]/g, '-') }
|
|
|
|
;(async () => {
|
|
const START_TS = Date.now()
|
|
const logFile = path.join(ARTIFACT_DIR, 'e2e.log')
|
|
try { fs.writeFileSync(logFile, '') } catch(e){}
|
|
const log = (msg, ...rest) => {
|
|
const t = new Date().toISOString()
|
|
const extra = rest && rest.length ? ' ' + rest.map(r => (typeof r === 'string' ? r : JSON.stringify(r))).join(' ') : ''
|
|
const line = `[${t}] ${msg}${extra}`
|
|
console.log(line)
|
|
try { fs.appendFileSync(logFile, line + '\n') } catch(e){}
|
|
}
|
|
|
|
try {
|
|
const BACKEND = process.env.TOKEN_SERVER || 'http://localhost:4000'
|
|
const BROADCAST_URL = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host'
|
|
const BROWSERLESS_TOKEN = process.env.BROWSERLESS_TOKEN || process.env.BROWSERLESS || process.env.TOKEN || ''
|
|
if (!BROWSERLESS_TOKEN) {
|
|
log('BROWSERLESS_TOKEN not set in env (use BROWSERLESS_TOKEN=...)')
|
|
process.exit(1)
|
|
}
|
|
|
|
// timeouts and retries
|
|
const OVERLAY_WAIT_MS = 60 * 1000 // 60s
|
|
const POLL_INTERVAL_MS = 1000
|
|
|
|
log('Creating session on backend:', BACKEND)
|
|
const res = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ room: 'e2e-room', username: 'e2e-runner' })
|
|
})
|
|
log('Session create status:', res.status)
|
|
const raw = await res.text().catch(() => '')
|
|
log('Session raw response:', raw)
|
|
if (!res.ok) {
|
|
const t = raw
|
|
throw new Error('Failed to create session: ' + res.status + ' ' + t)
|
|
}
|
|
const j = JSON.parse(raw)
|
|
log('Parsed session keys:', Object.keys(j))
|
|
const livekitToken = j.token
|
|
const livekitUrl = j.url || j.studioUrl || j.redirectUrl || ''
|
|
log('Got session token (trunc):', livekitToken ? livekitToken.slice(0, 40) + '...' : '(none)')
|
|
|
|
const wsEndpoint = `wss://browserless.bfzqqk.easypanel.host?token=${BROWSERLESS_TOKEN}`
|
|
log('Connecting to browserless WS endpoint:', wsEndpoint)
|
|
const browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, defaultViewport: { width: 1400, height: 900 } })
|
|
const page = await browser.newPage()
|
|
|
|
// Instrumentation containers
|
|
const networkEvents = []
|
|
const consoleEvents = []
|
|
const pageErrors = []
|
|
|
|
// Capture console
|
|
page.on('console', async msg => {
|
|
try {
|
|
const args = []
|
|
for (let i = 0; i < msg.args().length; i++) {
|
|
try { const a = await msg.args()[i].jsonValue(); args.push(a) } catch(e) { args.push(msg.args()[i].toString()) }
|
|
}
|
|
const text = msg.text()
|
|
consoleEvents.push({ ts: Date.now(), type: msg.type(), text, args })
|
|
log('[page console]', msg.type(), text, args.length ? JSON.stringify(args) : '')
|
|
} catch(e) { log('[page console error]', e.message) }
|
|
})
|
|
page.on('pageerror', err => {
|
|
pageErrors.push({ ts: Date.now(), error: String(err && err.stack ? err.stack : err) })
|
|
log('[page error]', err && err.stack ? err.stack : String(err))
|
|
})
|
|
|
|
// Capture network requests/responses
|
|
page.on('request', req => {
|
|
try { networkEvents.push({ type: 'request', ts: Date.now(), url: req.url(), method: req.method(), headers: req.headers() }) } catch(e){}
|
|
})
|
|
page.on('response', async res => {
|
|
try {
|
|
const url = res.url()
|
|
const status = res.status()
|
|
const headers = res.headers()
|
|
let body = ''
|
|
try {
|
|
const ct = headers['content-type'] || ''
|
|
if (ct.includes('application/json') || ct.includes('text/') || ct.includes('application/javascript')) {
|
|
body = await res.text()
|
|
if (body && body.length > 2000) body = body.slice(0, 2000) + '...[truncated]'
|
|
}
|
|
} catch(e) { body = '[error reading body]' }
|
|
networkEvents.push({ type: 'response', ts: Date.now(), url, status, headers, body })
|
|
} catch(e){}
|
|
})
|
|
page.on('requestfailed', req => { networkEvents.push({ type: 'requestfailed', ts: Date.now(), url: req.url(), err: req.failure && req.failure.errorText }) })
|
|
|
|
// Start tracing
|
|
const traceFile = path.join(ARTIFACT_DIR, `trace-${now()}.json`)
|
|
try {
|
|
await page.tracing.start({ path: traceFile, screenshots: true })
|
|
log('Started page tracing ->', traceFile)
|
|
} catch (e) { log('Tracing start failed:', e.message) }
|
|
|
|
// Navigate with token in URL (preferred)
|
|
const encodedUrl = encodeURIComponent(livekitUrl || '')
|
|
const urlWithToken = `${BROADCAST_URL.replace(/\/$/, '')}?token=${encodeURIComponent(livekitToken || '')}&url=${encodedUrl}&room=${encodeURIComponent(j.room || 'e2e-room')}`
|
|
log('Navigating to broadcast URL with token')
|
|
await page.goto(urlWithToken, { waitUntil: 'networkidle2', timeout: 120000 })
|
|
|
|
// Give SPA time to hydrate
|
|
await page.waitForTimeout(1500)
|
|
|
|
// Helper to try clicking buttons by text (robust)
|
|
async function clickButtonByText(texts, timeout = 3000) {
|
|
const start = Date.now()
|
|
while (Date.now() - start < timeout) {
|
|
for (const t of texts) {
|
|
const handles = await page.$x(`//button[contains(normalize-space(.), "${t}")]`)
|
|
if (handles && handles.length > 0) {
|
|
try { await handles[0].click(); log('Clicked button', t); return true } catch(e) { log('Click error', e.message) }
|
|
}
|
|
}
|
|
await page.waitForTimeout(300)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Wait for UI
|
|
try { await page.waitForFunction(() => !!document.querySelector('body'), { timeout: 20000 }); log('Page body present') } catch(e) { log('Page body missing?') }
|
|
|
|
// Try to click Entrar al estudio and follow creation flow
|
|
const clickedEntrar = await clickButtonByText(['Entrar al estudio'], 8000)
|
|
if (clickedEntrar) {
|
|
log('Clicked Entrar al estudio (first attempt)')
|
|
// click 'Omitir ahora' if appears
|
|
await clickButtonByText(['Omitir ahora', 'Omitar ahora', 'Skip for now', 'Skip'], 4000)
|
|
// fill modal input
|
|
try {
|
|
const inputHandle = await page.$(".modal input, .Dialog input, dialog input, input[placeholder*='Título'], input[placeholder*='titulo'], input[placeholder*='Transmi'], input[type='text']")
|
|
if (inputHandle) { try { await inputHandle.click({ clickCount: 3 }) } catch(e){}; await inputHandle.type('Transmision en Vivo', { delay: 40 }); log('Filled transmission title input') }
|
|
} catch(e){ log('fill title failed', e.message) }
|
|
await clickButtonByText(['Empezar ahora', 'Empezar', 'Iniciar', 'Start now', 'Start'], 5000)
|
|
await page.waitForTimeout(800)
|
|
await clickButtonByText(['Entrar al estudio'], 5000)
|
|
} else {
|
|
log('Entrar al estudio not clickable initially; proceeding to postMessage fallback')
|
|
}
|
|
|
|
// Post token via postMessage
|
|
const payload = { type: 'LIVEKIT_TOKEN', token: livekitToken, url: livekitUrl, room: j.room || 'e2e-room' }
|
|
log('Posting payload (trunc):', { type: payload.type, token: payload.token ? payload.token.slice(0,40) + '...' : null, url: payload.url, room: payload.room })
|
|
try { await page.evaluate((p) => { window.postMessage(p, window.location.origin); return true }, payload); log('postMessage executed') } catch(e) { log('postMessage failed', e.message) }
|
|
|
|
// Wait for overlay with polling
|
|
const overlaySelectors = ['.studio-portal', '.studioOverlay', '.studio-portal__center', '.studio-overlay']
|
|
const overlayStart = Date.now()
|
|
let overlayFound = false
|
|
while (Date.now() - overlayStart < OVERLAY_WAIT_MS) {
|
|
for (const sel of overlaySelectors) {
|
|
const el = await page.$(sel)
|
|
if (el) { overlayFound = true; log('Overlay detected by selector', sel); break }
|
|
}
|
|
if (overlayFound) break
|
|
await page.waitForTimeout(POLL_INTERVAL_MS)
|
|
}
|
|
|
|
if (!overlayFound) log('Overlay not detected within timeout, capturing extra artifacts for debugging')
|
|
else log('Overlay successfully detected')
|
|
|
|
// Save artifacts
|
|
const screenshotPath = path.join(ARTIFACT_DIR, `screenshot-${now()}.png`)
|
|
await page.screenshot({ path: screenshotPath, fullPage: false })
|
|
log('Screenshot saved to', screenshotPath)
|
|
|
|
try { const html = await page.content(); const htmlPath = path.join(ARTIFACT_DIR, `page-${now()}.html`); fs.writeFileSync(htmlPath, html); log('Saved page HTML to', htmlPath) } catch(e){ log('Failed saving HTML', e.message) }
|
|
try { const netPath = path.join(ARTIFACT_DIR, `network-${now()}.json`); fs.writeFileSync(netPath, JSON.stringify(networkEvents, null, 2)); log('Saved network events to', netPath) } catch(e){ log('Failed saving network events', e.message) }
|
|
try { const consolePath = path.join(ARTIFACT_DIR, `console-${now()}.json`); fs.writeFileSync(consolePath, JSON.stringify(consoleEvents, null, 2)); log('Saved console events to', consolePath) } catch(e){ log('Failed saving console events', e.message) }
|
|
try { const pageErrorsPath = path.join(ARTIFACT_DIR, `page-errors-${now()}.json`); fs.writeFileSync(pageErrorsPath, JSON.stringify(pageErrors, null, 2)); log('Saved page errors to', pageErrorsPath) } catch(e){ log('Failed saving page errors', e.message) }
|
|
|
|
try { await page.tracing.stop(); log('Stopped tracing; trace saved to', traceFile) } catch(e){ log('Tracing stop failed', e.message) }
|
|
|
|
await browser.close()
|
|
log('Browser closed')
|
|
|
|
const duration = (Date.now() - START_TS) / 1000
|
|
log('E2E run finished in', duration + 's')
|
|
|
|
const files = fs.readdirSync(ARTIFACT_DIR).map(f => path.join(ARTIFACT_DIR, f))
|
|
log('Artifacts produced:', files)
|
|
process.exit(overlayFound ? 0 : 3)
|
|
|
|
} catch (err) {
|
|
try { fs.appendFileSync(path.join(ARTIFACT_DIR, 'e2e.log'), String(err && err.stack ? err.stack : err) + '\n') } catch(e){}
|
|
console.error('E2E failed:', err && err.stack ? err.stack : err)
|
|
process.exit(2)
|
|
}
|
|
})()
|