AvanzaCast/packages/meet/scripts/start_meeting_capture.js
Cesar Mendivil 8b458a3ddf feat: add initial LiveKit Meet integration with utility scripts, configs, and core components
- 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
2025-11-20 12:50:38 -07:00

348 lines
13 KiB
JavaScript

#!/usr/bin/env node
// Puppeteer script to open a page, click on a button with text 'Start meeting' (or similar),
// capture network requests (XHR/fetch), detect wss/ws urls, save a screenshot and print a JSON summary.
import puppeteer from 'puppeteer';
import fs from 'fs';
async function findButtonByText(page, text) {
const xpath = `//button[contains(translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}') or contains(translate(@value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}') or contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')]`;
const els = await page.$x(xpath);
return els[0] || null;
}
async function findInputSelector(page) {
const selectors = [
'input[name="name"]',
'input[name="username"]',
'input[placeholder*="Name"]',
'input[placeholder*="name"]',
'input[placeholder*="Usuario"]',
'input[type="text"]',
'input#name',
'input#username',
'input[class*=name]',
'input[class*=user]'
];
for (const s of selectors) {
try {
const el = await page.$(s);
if (el) return { selector: s, element: el };
} catch (e) {}
}
// try to find input inside modal/dialogs
const inputs = await page.$$('input');
for (const inp of inputs) {
try {
const visible = await inp.boundingBox();
if (visible) return { selector: null, element: inp };
} catch (e) {}
}
return null;
}
async function clickButtonByTextVariants(page, variants) {
for (const v of variants) {
const btn = await findButtonByText(page, v);
if (btn) {
try { await btn.click({ delay: 50 }); return { found: true, text: v }; } catch(e) { return { found: false, error: String(e) }; }
}
}
return { found: false };
}
async function main() {
const url = process.argv[2] || 'http://192.168.1.19:3000';
const outPrefix = process.argv[3] || 'start_meeting_capture';
const headless = process.env.PW_HEADLESS !== '0';
const browser = await puppeteer.launch({ headless, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
const page = await browser.newPage();
const requests = [];
const responses = [];
const websocketUrls = new Set();
// Prepare an initial empty summary so we can return it even on early failure
let summary = {
requestedUrl: url,
finalUrl: null,
screenshot: null,
requestsCount: 0,
responsesCount: 0,
websocketUrls: [],
requests: [],
responses: [],
console: [],
pageErrors: [],
foundToken: null,
foundSessionId: null,
validate: { checked: false }
};
page.on('request', (req) => {
const r = { id: req._requestId || null, url: req.url(), method: req.method(), resourceType: req.resourceType(), headers: req.headers() };
// try to capture postData if available
try { r.postData = req.postData ? req.postData() : null; } catch(e) {}
requests.push(r);
});
page.on('response', async (resp) => {
try {
const req = resp.request();
const entry = { url: resp.url(), status: resp.status(), statusText: resp.statusText(), headers: resp.headers(), request: { url: req.url(), method: req.method(), headers: req.headers() } };
// If response is small JSON/text include preview
let text = null;
try {
const ct = (resp.headers()['content-type'] || resp.headers()['Content-Type'] || '') + '';
if (ct.includes('application/json') || ct.includes('text/')) {
text = await resp.text();
if (text && text.length > 2048) text = text.slice(0, 2048) + '...';
}
} catch (e) {}
if (text) entry.bodyPreview = text;
responses.push(entry);
if (resp.status() === 101) websocketUrls.add(resp.url());
} catch (e) {
// ignore
}
});
// also capture console messages
const consoleMessages = [];
page.on('console', (msg) => {
try {
const args = msg.args ? msg.args.map(a => { try { return a.jsonValue(); } catch (e) { return String(a); } }) : [];
consoleMessages.push({ type: msg.type(), text: msg.text(), args });
} catch (e) {}
});
// catch page errors
const pageErrors = [];
page.on('pageerror', err => pageErrors.push(String(err)));
let finalUrl = url;
let navigationFailed = false;
try {
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
finalUrl = page.url();
} catch (e) {
// handle connection refused or other navigation errors gracefully
console.error('Failed to load page:', String(e));
navigationFailed = true;
}
// update summary basics
summary.requestedUrl = url;
summary.finalUrl = finalUrl;
// try to find start button
const possibleTexts = ['start meeting','start','join meeting','start session','start meeting now','iniciar reunión','iniciar','comenzar','abrir estudio'];
let btn = null;
if (!navigationFailed) {
for (const t of possibleTexts) {
btn = await findButtonByText(page, t);
if (btn) { console.log('Found button with text:', t); break; }
}
if (!btn) {
// also try common selectors
const selectors = ['button.start','button.join','button#start','button#join','button[data-action="start"]'];
for (const s of selectors) {
try {
const el = await page.$(s);
if (el) { btn = el; console.log('Found button by selector:', s); break; }
} catch(e){}
}
}
} else {
console.warn('Skipping button search because navigation failed');
}
if (btn) {
try {
await btn.click({ delay: 50 });
console.log('Clicked start button, waiting for network activity...');
try { await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 8000 }); finalUrl = page.url(); } catch (e) {}
await page.waitForTimeout(800);
// After clicking Start, try to find input for name and Join button
console.log('Looking for name input...');
const inputInfo = await findInputSelector(page);
if (inputInfo && inputInfo.element) {
try {
console.log('Found input selector:', inputInfo.selector || '<detected input>');
await inputInfo.element.focus();
await page.keyboard.type('Xesar', { delay: 80 });
await page.waitForTimeout(300);
} catch(e){ console.warn('Failed to type into input', String(e)); }
} else {
console.log('No obvious input found; trying to find input with dialog heuristics...');
}
// Click Join button (variants)
const joinVariants = ['join room','join','join meeting','join now','join room','unirse','unirse a la sala','entrar','entrar al estudio','join session','join room'];
const joinRes = await clickButtonByTextVariants(page, joinVariants.map(v => v + ''));
if (joinRes && joinRes.found) {
console.log('Clicked join button, variant:', joinRes.text);
try { await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 8000 }); finalUrl = page.url(); } catch(e) {}
await page.waitForTimeout(1200);
} else {
console.log('Join button not found by text variants, trying selectors...');
const joinSelectors = ['button.join','button#join','button[data-action="join"]','button[data-action="enter"]','button.btn-primary'];
for (const s of joinSelectors) {
try {
const el = await page.$(s);
if (el) { await el.click({ delay: 50 }); console.log('Clicked join selector', s); try { await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 8000 }); finalUrl = page.url(); } catch(e){} break; }
} catch(e){}
}
}
} catch (e) { console.error('Failed to click start/join flow:', String(e)); }
} else {
console.warn('Start button not found on page.');
}
// snapshot
const screenshotPath = `/tmp/${outPrefix}.png`;
try { await page.screenshot({ path: screenshotPath, fullPage: true }); summary.screenshot = screenshotPath; } catch (e) { summary.screenshot = null; }
// try to detect websocket connections from requests list
for (const r of requests) {
if (r.url.startsWith('ws://') || r.url.startsWith('wss://')) websocketUrls.add(r.url);
}
// Attempt to extract token or session id from responses or requests
let foundToken = null;
let foundSessionId = null;
try {
// search responses for token or id
for (const resp of responses) {
try {
const body = resp.bodyPreview;
if (!body) continue;
// try JSON
let parsed = null;
try { parsed = JSON.parse(body); } catch(e) { /* not json */ }
if (parsed) {
// common shapes: { token }, { token: '...', url: '...', id: '...' }, { id, studioUrl }
if (!foundToken && (parsed.token || parsed.access_token || parsed.data?.token)) {
foundToken = parsed.token || parsed.access_token || parsed.data?.token;
}
if (!foundSessionId && (parsed.id || parsed.sessionId || parsed.data?.id)) {
foundSessionId = parsed.id || parsed.sessionId || parsed.data?.id;
}
// sometimes token nested
for (const k of Object.keys(parsed)) {
if (!foundToken && typeof parsed[k] === 'string' && parsed[k].startsWith('eyJ')) {
foundToken = parsed[k];
}
}
} else {
// search raw text for token-like pattern
const m = body.match(/"token"\s*:\s*"([A-Za-z0-9-_\.]+)"/);
if (m) foundToken = m[1];
const m2 = body.match(/"id"\s*:\s*"([A-Za-z0-9-_]+)"/);
if (m2 && !foundSessionId) foundSessionId = m2[1];
}
} catch(e) {}
if (foundToken && foundSessionId) break;
}
// search requests postData
if (!foundToken || !foundSessionId) {
for (const req of requests) {
try {
const pd = req.postData;
if (!pd) continue;
let parsed = null;
try { parsed = JSON.parse(pd); } catch(e) {}
if (parsed) {
if (!foundToken && (parsed.token || parsed.access_token)) foundToken = parsed.token || parsed.access_token;
if (!foundSessionId && (parsed.id || parsed.sessionId || parsed.room)) {
// sometimes they send room and server creates session id; keep room for debug
foundSessionId = parsed.id || parsed.sessionId;
}
// also check nested
for (const k of Object.keys(parsed)) {
if (!foundToken && typeof parsed[k] === 'string' && parsed[k].startsWith('eyJ')) foundToken = parsed[k];
}
}
} catch(e) {}
if (foundToken && foundSessionId) break;
}
}
} catch(e) {
// ignore
}
const validateResult = { checked: false };
try {
const BACKEND = process.env.BACKEND || 'http://localhost:4000';
// If we found a session id but no token, try to fetch token via backend API
if (!foundToken && foundSessionId) {
try {
const r = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session/${encodeURIComponent(foundSessionId)}/token`);
if (r.ok) {
const js = await r.json();
if (js && js.token) foundToken = js.token;
}
} catch(e) { /* ignore */ }
}
// If we have a token, call validate proxy
if (foundToken) {
validateResult.checked = true;
try {
const r2 = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session/validate?token=${encodeURIComponent(foundToken)}`);
const ct = r2.headers.get('content-type') || '';
const text = await r2.text();
let body = null;
try { body = JSON.parse(text); } catch(e) { body = text }
validateResult.ok = r2.ok;
validateResult.status = r2.status;
validateResult.body = body;
} catch (e) {
validateResult.error = String(e);
}
// decode JWT header to inspect alg
try {
const parts = foundToken.split('.');
if (parts.length >= 2) {
const header = parts[0];
const padded = header + '='.repeat((4 - (header.length % 4)) % 4);
const decoded = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
try { validateResult.header = JSON.parse(decoded); } catch(e) { validateResult.header = decoded; }
}
} catch(e) { /* ignore */ }
}
} catch(e) {
validateResult.error = String(e);
}
// After detection/validation, populate summary counts and arrays
try {
summary.requestsCount = requests.length;
summary.responsesCount = responses.length;
summary.websocketUrls = Array.from(websocketUrls);
summary.requests = requests.slice(-100);
summary.responses = responses.slice(-100);
summary.console = consoleMessages.slice(-200);
summary.pageErrors = pageErrors.slice(-50);
} catch (e) { /* ignore */ }
// attach foundToken/sessionId to summary for convenience
summary.foundToken = foundToken || null;
summary.foundSessionId = foundSessionId || null;
summary.validate = validateResult;
const outPath = `/tmp/${outPrefix}.json`;
fs.writeFileSync(outPath, JSON.stringify(summary, null, 2));
console.log('Wrote summary to', outPath);
console.log(JSON.stringify({ summaryPath: outPath, screenshot: summary.screenshot }));
// Print the summary to stdout for environments where /tmp isn't accessible
try { console.log(JSON.stringify({ summary }, null, 2)); } catch(e) { console.log('Failed to print summary to stdout', String(e)); }
await browser.close();
}
main().catch(err => { console.error('Error in script', err); process.exit(1); });