330 lines
19 KiB
JavaScript
330 lines
19 KiB
JavaScript
// e2e/validate-flow-remote-chrome.js
|
|
// Connect to a remote Chrome (remote debugging port 9222) and run the Broadcast->Studio E2E flow.
|
|
// Usage:
|
|
// CHROME_WS (optional) = full websocketDebuggerUrl
|
|
// CHROME_HOST (optional) = host:port (default port 9222) e.g. 1.2.3.4 or 1.2.3.4:9222
|
|
// BROADCAST_URL (required) - broadcast panel URL
|
|
// STUDIO_URL (optional)
|
|
// TOKEN (optional) - token to append when forcing studio navigation
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const fetch = require('node-fetch');
|
|
const puppeteer = require('puppeteer-core');
|
|
|
|
async function getWsEndpointFromHost(host) {
|
|
// host maybe '1.2.3.4' or '1.2.3.4:9222'
|
|
const url = host.includes(':') ? `http://${host}/json/version` : `http://${host}:9222/json/version`;
|
|
const res = await fetch(url, { timeout: 5000 }).catch(err => { throw new Error(`Failed to fetch ${url}: ${err.message}`); });
|
|
if (!res.ok) throw new Error(`Failed to get json/version from ${url}: status=${res.status}`);
|
|
const json = await res.json();
|
|
if (!json.webSocketDebuggerUrl) throw new Error(`No webSocketDebuggerUrl in ${url} response`);
|
|
return json.webSocketDebuggerUrl;
|
|
}
|
|
|
|
(async () => {
|
|
const outDir = path.resolve(__dirname);
|
|
const results = { startedAt: new Date().toISOString(), console: [], navigations: [] };
|
|
|
|
const CHROME_WS = process.env.CHROME_WS || null;
|
|
const CHROME_HOST = process.env.CHROME_HOST || process.env.CHROME_REMOTE || null; // host[:port]
|
|
let wsEndpoint = CHROME_WS;
|
|
|
|
try {
|
|
if (!wsEndpoint) {
|
|
if (!CHROME_HOST) throw new Error('CHROME_WS or CHROME_HOST required (host[:port] or ws url)');
|
|
wsEndpoint = await getWsEndpointFromHost(CHROME_HOST);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to determine Chrome websocket endpoint:', err.message || err);
|
|
process.exit(2);
|
|
}
|
|
|
|
console.log('Using Chrome websocket endpoint:', wsEndpoint);
|
|
|
|
const BROADCAST_URL = process.env.BROADCAST_URL || process.env.VITE_BROADCASTPANEL_URL || null;
|
|
const STUDIO_URL = process.env.STUDIO_URL || process.env.VITE_STUDIO_URL || null;
|
|
const TOKEN = process.env.TOKEN || process.env.E2E_TOKEN || '';
|
|
|
|
if (!BROADCAST_URL) {
|
|
console.error('BROADCAST_URL is required');
|
|
process.exit(2);
|
|
}
|
|
|
|
let browser;
|
|
try {
|
|
browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, ignoreHTTPSErrors: true, defaultViewport: { width: 1366, height: 768 } });
|
|
} catch (err) {
|
|
console.error('Failed to connect to remote Chrome:', 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 {
|
|
await page.goto(BROADCAST_URL, { waitUntil: 'networkidle2' });
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Try to find a button/link to enter the studio
|
|
const texts = ['Entrar al estudio', 'Entrar al Studio', 'Entrar al Estudio', 'Enter studio', 'Enter the studio'];
|
|
let studioOpened = 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 {
|
|
// prepare for new target
|
|
const popupPromise = new Promise(resolve => {
|
|
const onTarget = target => resolve(target);
|
|
browser.once('targetcreated', onTarget);
|
|
setTimeout(() => { try { browser.removeListener('targetcreated', onTarget); } catch(e){}; resolve(null); }, 4000);
|
|
});
|
|
|
|
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')) studioPage = page;
|
|
}
|
|
|
|
if (studioPage) {
|
|
console.log('Studio page found; navigating with token...');
|
|
// Prefer STUDIO_URL if provided; otherwise use BROADCAST_URL (strip trailing slash) as entry with ?token
|
|
const broadcastBase = (BROADCAST_URL || '').replace(/\/$/, '');
|
|
const targetStudioUrl = STUDIO_URL && STUDIO_URL.length ? `${STUDIO_URL}?token=${encodeURIComponent(TOKEN)}` : `${broadcastBase}?token=${encodeURIComponent(TOKEN)}`;
|
|
await studioPage.goto(targetStudioUrl, { waitUntil: 'networkidle2' });
|
|
results.navigations.push({ type: 'studio_opened', url: studioPage.url() });
|
|
await studioPage.waitForTimeout(2500);
|
|
|
|
// --- ASSERTIONS: check sessionStorage on broadcast page and content on studio page
|
|
results.assertions = results.assertions || [];
|
|
try {
|
|
const storeKey = 'avanzacast_studio_session';
|
|
const stored = await page.evaluate((k) => { try { return sessionStorage.getItem(k); } catch(e) { return null; } }, storeKey);
|
|
if (stored) {
|
|
try {
|
|
const parsed = JSON.parse(stored);
|
|
// Accept any token returned by backend; if TOKEN env is set we also note if it matches
|
|
// Basic presence checks
|
|
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 });
|
|
|
|
// Token format: JWT-like (3 parts)
|
|
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) {
|
|
// decode payload safely (base64url)
|
|
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' });
|
|
}
|
|
|
|
// Check room and url fields if present
|
|
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' });
|
|
}
|
|
|
|
// Additional optional assertions: username and TTL
|
|
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 {
|
|
// still assert presence of username if not explicitly expected
|
|
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' });
|
|
}
|
|
|
|
// TTL checks: looks for ttlSeconds, ttl or expiresAt
|
|
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) {
|
|
// expiresAt may be timestamp or Date string
|
|
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 there's an id/sessionId available, try to query backend token endpoint for extra validation
|
|
if (parsed && (parsed.id || parsed.sessionId)) {
|
|
const sid = parsed.id || parsed.sessionId;
|
|
try {
|
|
// attempt to fetch from broadcast origin first
|
|
const base = (BROADCAST_URL || '').replace(/\/$/, '');
|
|
const tokenEndpoint = `${base}/api/session/${encodeURIComponent(sid)}/token`;
|
|
let fetched = null;
|
|
try {
|
|
const r = await (await import('node-fetch'))(tokenEndpoint);
|
|
if (r && r.ok) fetched = await r.json().catch(() => null);
|
|
} catch (fe) {
|
|
// ignore - will try absolute later
|
|
fetched = null;
|
|
}
|
|
if (!fetched) {
|
|
// try absolute token server env if available
|
|
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 r2 = await (await import('node-fetch'))(abs, { mode: 'cors' });
|
|
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' });
|
|
// If backend returned ttlSeconds, validate against minTtl
|
|
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) });
|
|
}
|
|
|
|
try {
|
|
const bodyText = await studioPage.evaluate(() => (document.body && document.body.innerText) ? document.body.innerText : '');
|
|
if (bodyText.includes('token=') || (TOKEN && bodyText.includes(TOKEN.slice(0,8)))) {
|
|
results.assertions.push({ name: 'studio_page_shows_token', ok: true, detail: 'Studio page contains token or token prefix' });
|
|
} else {
|
|
results.assertions.push({ name: 'studio_page_shows_token', ok: false, detail: 'Studio page does not show token text' });
|
|
}
|
|
} catch (e) {
|
|
results.assertions.push({ name: 'studio_page_check_failed', ok: false, detail: String(e) });
|
|
}
|
|
|
|
const shot = path.join(outDir, 'validate-remote-chrome-result.png');
|
|
await studioPage.screenshot({ path: shot, fullPage: true });
|
|
results.screenshot = shot;
|
|
studioOpened = true;
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
console.warn('Click attempt error', err && err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!studioOpened) {
|
|
// fallback: navigate directly to studio 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);
|
|
const shot = path.join(outDir, 'validate-remote-chrome-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-remote-chrome-result.json');
|
|
fs.writeFileSync(outJson, JSON.stringify(results, null, 2));
|
|
console.log('Wrote results to', outJson);
|
|
|
|
// append log and publish artifact
|
|
try {
|
|
const { publishArtifact, appendLog } = require('./logging');
|
|
const artifactUrl = publishArtifact(outJson, 'validate-flow-remote-chrome') || null;
|
|
appendLog('validate-flow-remote-chrome', outJson, results, artifactUrl);
|
|
} catch (e) { console.warn('Failed to write LOG.md entry', e); }
|
|
|
|
try { await page.close(); } catch(e){}
|
|
try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} }
|
|
process.exit(0);
|
|
} catch (err) {
|
|
console.error('Error validating flow on remote chrome', 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-remote-chrome-result.json'), JSON.stringify(results, null, 2));
|
|
try { await page.close(); } catch(e){}
|
|
try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} }
|
|
process.exit(1);
|
|
}
|
|
})();
|