- 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
348 lines
13 KiB
JavaScript
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); });
|