// e2e/validate-flow-browserless.js // Connects to a browserless WebSocket endpoint and validates the BroadcastPanel -> Studio flow // Expects env vars: // BROWSERLESS_WS (wss://...) // BROWSERLESS_TOKEN (optional) // VITE_BROADCASTPANEL_URL or BROADCAST_URL // VITE_STUDIO_URL or STUDIO_URL (optional, fallback to page's url) // TOKEN or E2E_TOKEN (token to pass to studio) const fs = require('fs'); const path = require('path'); const puppeteer = require('puppeteer-core'); // Helper: evaluate sessionStorage and backend token assertions async function evaluateSessionAssertions(broadcastPage, studioPage, results, BROADCAST_URL, TOKEN) { results.assertions = results.assertions || []; try { const storeKey = 'avanzacast_studio_session'; const stored = await broadcastPage.evaluate((k) => { try { return sessionStorage.getItem(k); } catch (e) { return null; } }, storeKey); if (stored) { try { const parsed = JSON.parse(stored); if (parsed && parsed.token) { const note = (typeof TOKEN === 'string' && TOKEN.length && String(parsed.token).includes(TOKEN.slice(0,6))) ? 'sessionStorage contains token (matches provided TOKEN prefix)' : 'sessionStorage contains token (from backend)'; results.assertions.push({ name: 'sessionStorage_has_token', ok: true, detail: note }); try { const parts = String(parsed.token).split('.'); const isJwt = parts.length >= 3; results.assertions.push({ name: 'token_format_jwt', ok: isJwt, detail: isJwt ? 'looks like JWT' : 'not JWT-like' }); if (isJwt) { const payloadB64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); const pad = '='.repeat((4 - (payloadB64.length % 4)) % 4); const decoded = Buffer.from(payloadB64 + pad, 'base64').toString('utf8'); try { const payloadObj = JSON.parse(decoded); const nowSec = Math.floor(Date.now() / 1000); if (payloadObj.exp && typeof payloadObj.exp === 'number') { results.assertions.push({ name: 'token_not_expired', ok: payloadObj.exp > nowSec, detail: `exp=${payloadObj.exp} now=${nowSec}` }); } else { results.assertions.push({ name: 'token_has_exp', ok: false, detail: 'exp missing or not a number' }); } if (payloadObj.room) results.assertions.push({ name: 'token_payload_room', ok: true, detail: String(payloadObj.room) }); } catch (e) { results.assertions.push({ name: 'token_payload_parse', ok: false, detail: 'failed to parse payload: ' + String(e) }); } } } catch (e) { results.assertions.push({ name: 'token_format_check_failed', ok: false, detail: String(e) }); } } else { results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage present but token missing or malformed' }); } if (parsed && parsed.room) { results.assertions.push({ name: 'session_room_present', ok: true, detail: String(parsed.room) }); } else { results.assertions.push({ name: 'session_room_present', ok: false, detail: 'room missing in session payload' }); } if (parsed && (parsed.url || parsed.serverUrl)) { const serverUrl = parsed.url || parsed.serverUrl; const okUrl = typeof serverUrl === 'string' && (serverUrl.startsWith('ws://') || serverUrl.startsWith('wss://') || serverUrl.startsWith('http://') || serverUrl.startsWith('https://')); results.assertions.push({ name: 'session_serverUrl_valid', ok: okUrl, detail: okUrl ? String(serverUrl) : 'invalid or missing serverUrl' }); } else { results.assertions.push({ name: 'session_serverUrl_valid', ok: false, detail: 'serverUrl missing' }); } try { const expectedRoom = process.env.EXPECTED_ROOM || null; const expectedUsername = process.env.EXPECTED_USERNAME || null; const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30); if (expectedRoom) { const roomMatches = !!parsed && String(parsed.room) === String(expectedRoom); results.assertions.push({ name: 'session_room_matches_expected', ok: roomMatches, detail: roomMatches ? `room matches ${expectedRoom}` : `expected ${expectedRoom} got ${String(parsed.room)}` }); } if (expectedUsername) { const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName); const unameMatches = !!uname && String(uname) === String(expectedUsername); results.assertions.push({ name: 'session_username_matches_expected', ok: unameMatches, detail: unameMatches ? `username matches ${expectedUsername}` : `expected ${expectedUsername} got ${String(uname)}` }); } else { const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName); results.assertions.push({ name: 'session_username_present', ok: !!uname, detail: !!uname ? `username: ${String(uname)}` : 'username missing' }); } let ttlSeconds = null; if (parsed && typeof parsed.ttlSeconds === 'number') ttlSeconds = parsed.ttlSeconds; else if (parsed && typeof parsed.ttl === 'number') ttlSeconds = parsed.ttl; else if (parsed && parsed.expiresAt) { const ex = typeof parsed.expiresAt === 'number' ? parsed.expiresAt : Date.parse(String(parsed.expiresAt)); if (!Number.isNaN(ex)) ttlSeconds = Math.max(0, Math.floor((ex - Date.now()) / 1000)); } if (ttlSeconds !== null) { results.assertions.push({ name: 'session_ttl_seconds', ok: true, detail: `ttlSeconds=${ttlSeconds}` }); results.assertions.push({ name: 'session_ttl_minimum', ok: ttlSeconds >= minTtl, detail: `ttl=${ttlSeconds} min=${minTtl}` }); } else { results.assertions.push({ name: 'session_ttl_seconds', ok: false, detail: 'ttl not found in session payload' }); } } catch (e) { results.assertions.push({ name: 'session_ttl_check_failed', ok: false, detail: String(e) }); } if (parsed && (parsed.id || parsed.sessionId)) { const sid = parsed.id || parsed.sessionId; try { const base = (BROADCAST_URL || '').replace(/\/$/, ''); const tokenEndpoint = `${base}/api/session/${encodeURIComponent(sid)}/token`; let fetched = null; try { const fetchMod = await import('node-fetch'); const r = await fetchMod.default(tokenEndpoint).catch(() => null); if (r && r.ok) fetched = await r.json().catch(() => null); } catch (fe) { fetched = null } if (!fetched) { const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || process.env.VITE_TOKEN_SERVER_URL || process.env.VITE_BACKEND_TOKENS_URL || null; if (TOKEN_SERVER) { const abs = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(sid)}/token`; try { const fetchMod2 = await import('node-fetch'); const r2 = await fetchMod2.default(abs).catch(() => null); if (r2 && r2.ok) fetched = await r2.json().catch(() => null); } catch (fe2) { fetched = null } } } if (fetched && fetched.token) { results.assertions.push({ name: 'backend_token_fetch', ok: true, detail: 'fetched token from backend' }); try { const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30); const bttl = fetched.ttlSeconds || fetched.ttl || null; if (typeof bttl === 'number') { results.assertions.push({ name: 'backend_ttl_seconds', ok: true, detail: `backend ttlSeconds=${bttl}` }); results.assertions.push({ name: 'backend_ttl_minimum', ok: bttl >= minTtl, detail: `backend ttl=${bttl} min=${minTtl}` }); } else { results.assertions.push({ name: 'backend_ttl_seconds', ok: false, detail: 'backend ttl not present or not numeric' }); } } catch (e) { results.assertions.push({ name: 'backend_ttl_check_failed', ok: false, detail: String(e) }); } } else { results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'could not fetch token for session id' }); } } catch (e) { results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'error: ' + String(e) }); } } } catch (e) { results.assertions.push({ name: 'sessionStorage_parse', ok: false, detail: 'sessionStorage JSON parse failed: ' + String(e) }); } } else { results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage key not found' }); } } catch (e) { results.assertions.push({ name: 'sessionStorage_check_failed', ok: false, detail: String(e) }); } } (async () => { const outDir = path.resolve(__dirname); const results = { startedAt: new Date().toISOString(), console: [], navigations: [] }; let ws = process.env.BROWSERLESS_WS || process.env.BWS || ''; const btoken = process.env.BROWSERLESS_TOKEN || process.env.BWS_TOKEN || ''; try { if (ws && btoken) { const hasQuery = ws.includes('?'); ws = hasQuery ? `${ws}&token=${encodeURIComponent(btoken)}` : `${ws}?token=${encodeURIComponent(btoken)}`; } } catch (e) {} if (!ws) { console.error('BROWSERLESS_WS is required'); process.exit(2); } const BROADCAST_URL = process.env.VITE_BROADCASTPANEL_URL || process.env.BROADCAST_URL || ''; const STUDIO_URL = process.env.VITE_STUDIO_URL || process.env.STUDIO_URL || null; const TOKEN = process.env.TOKEN || process.env.E2E_TOKEN || ''; console.log('Connecting to browserless at', ws); let browser; try { browser = await puppeteer.connect({ browserWSEndpoint: ws, ignoreHTTPSErrors: true, defaultViewport: { width: 1366, height: 768 } }); } catch (err) { console.error('Failed to connect to browserless:', err && err.message ? err.message : err); process.exit(2); } const page = await browser.newPage(); page.setDefaultNavigationTimeout(30000); page.on('console', msg => { try { results.console.push({ type: msg.type(), text: msg.text() }); } catch (e) {} }); page.on('pageerror', err => { results.console.push({ type: 'pageerror', text: String(err && err.stack ? err.stack : err) }); }); try { if (!BROADCAST_URL) { console.error('BROADCAST_URL (VITE_BROADCASTPANEL_URL) is required'); await browser.disconnect(); process.exit(2); } await page.goto(BROADCAST_URL, { waitUntil: 'networkidle2' }); await page.waitForTimeout(1500); const texts = ['Entrar al estudio', 'Entrar al Studio', 'Entrar al Estudio', 'Entrar al Studio', 'Enter studio', 'Enter the studio', 'Enter the studio']; let clicked = false; for (const t of texts) { const els = await page.$x(`//a[contains(normalize-space(.), '${t}')] | //button[contains(normalize-space(.), '${t}')] | //span[contains(normalize-space(.), '${t}')]`); if (els && els.length) { console.log('Found element for text:', t, 'count=', els.length); try { // Try click and wait for targetcreated or navigation const popupPromise = new Promise(resolve => { const onTarget = target => { resolve(target); }; browser.once('targetcreated', onTarget); setTimeout(() => { try { browser.removeListener('targetcreated', onTarget) } catch(e){}; resolve(null); }, 3000); }); await els[0].click({ delay: 50 }).catch(() => null); const popupTarget = await popupPromise; let studioPage = null; if (popupTarget) { try { studioPage = await popupTarget.page(); } catch (e) { studioPage = null; } } if (!studioPage) { await page.waitForTimeout(800); const url = page.url(); if (url.includes('/studio') || url.includes('studio') || url.includes('avanzacast-studio')) studioPage = page; } if (studioPage) { console.log('Studio page found; navigating with token...'); const targetStudioUrl = STUDIO_URL ? `${STUDIO_URL}?token=${encodeURIComponent(TOKEN)}` : `${studioPage.url().split('?')[0]}?token=${encodeURIComponent(TOKEN)}`; await studioPage.goto(targetStudioUrl, { waitUntil: 'networkidle2' }); results.navigations.push({ type: 'studio_opened', url: studioPage.url() }); await studioPage.waitForTimeout(2500); // Run assertions (sessionStorage + backend checks) await evaluateSessionAssertions(page, studioPage, results, BROADCAST_URL, TOKEN); const shot = path.join(outDir, 'studio_flow_browserless_result.png'); await studioPage.screenshot({ path: shot, fullPage: true }); results.screenshot = shot; clicked = true; break; } else { console.log('Click did not open studio for text:', t); } } catch (err) { console.warn('Click attempt error', err && err.message); } } } if (!clicked) { // fallback: try alternative selectors const altSel = 'a#enter-studio, button#enter-studio, a[data-enter-studio], button[data-enter-studio]'; try { const alt = await page.$(altSel); if (alt) { console.log('Found alternative selector, clicking...'); await alt.click().catch(() => null); await page.waitForTimeout(1000); } } catch (e) {} // fallback navigate directly to studio URL with token if (STUDIO_URL) { console.log('Fallback: navigating directly to STUDIO_URL with token'); const directUrl = `${STUDIO_URL}?token=${encodeURIComponent(TOKEN)}`; await page.goto(directUrl, { waitUntil: 'networkidle2' }); await page.waitForTimeout(1500); // Run assertions using current page as both broadcast and studio contexts await evaluateSessionAssertions(page, page, results, BROADCAST_URL, TOKEN); const shot = path.join(outDir, 'studio_flow_browserless_result.png'); await page.screenshot({ path: shot, fullPage: true }); results.screenshot = shot; results.navigations.push({ type: 'direct_studio', url: directUrl }); } else { console.error('No studio opened and no STUDIO_URL provided for fallback.'); } } results.endedAt = new Date().toISOString(); const outJson = path.join(outDir, 'validate-flow-browserless-result.json'); fs.writeFileSync(outJson, JSON.stringify(results, null, 2)); console.log('Wrote results to', outJson); // Publish artifact and append run summary to LOG.md try { const { publishArtifact, appendLog } = require('./logging'); const artifactUrl = publishArtifact(outJson, 'validate-flow-browserless') || null; appendLog('validate-flow-browserless', outJson, results, artifactUrl); } catch (e) { console.warn('Failed to write LOG.md entry', e); } try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} } process.exit(0); } catch (err) { console.error('Error validating flow', err && err.stack ? err.stack : err); results.error = String(err && err.stack ? err.stack : err); results.endedAt = new Date().toISOString(); fs.writeFileSync(path.join(outDir, 'validate-flow-browserless-result.json'), JSON.stringify(results, null, 2)); try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} } process.exit(1); } })();