#!/usr/bin/env node // Tier 1: Playwright + checks deterministas. Sin LLM. // // Uso: // node run.js # corre toda la suite de sites/.json // node run.js # corre solo una URL // node run.js --only=home-es,effa-hub // // Genera: // out///.png // out///report.json // out///report.md // out//latest -> symlink al último timestamp import { chromium } from 'playwright'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = __dirname; function parseArgs(argv) { const positional = []; const flags = {}; for (const a of argv.slice(2)) { if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); flags[k] = v ?? true; } else positional.push(a); } return { positional, flags }; } const { positional, flags } = parseArgs(process.argv); const siteName = positional[0]; if (!siteName) { console.error('Uso: node run.js [slug] [--only=a,b]'); process.exit(2); } const sitePath = path.join(ROOT, 'sites', `${siteName}.json`); if (!fs.existsSync(sitePath)) { console.error(`No existe ${sitePath}`); process.exit(2); } const site = JSON.parse(fs.readFileSync(sitePath, 'utf8')); let targets = site.urls; if (positional[1]) { targets = targets.filter(t => t.slug === positional[1]); } else if (flags.only) { const set = new Set(String(flags.only).split(',')); targets = targets.filter(t => set.has(t.slug)); } if (targets.length === 0) { console.error('No hay targets que correr'); process.exit(2); } const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const outDir = path.join(ROOT, 'out', siteName, ts); fs.mkdirSync(outDir, { recursive: true }); console.log(`[e2e] site=${siteName} targets=${targets.length} out=${outDir}`); // hostResolverRules permite apuntar el navegador al origen saltandose el DNS publico. // Hace falta para verificar un sitio que esta detras de Cloudflare: el WAF devuelve 403 // a las IPs desde las que corremos, asi que por el dominio real no se puede comprobar // nada. Formato de Chromium: "MAP www.ejemplo.com 1.2.3.4, MAP ejemplo.com 1.2.3.4". const launchArgs = site.hostResolverRules ? [`--host-resolver-rules=${site.hostResolverRules}`] : []; if (launchArgs.length) console.log(`[e2e] host-resolver-rules: ${site.hostResolverRules}`); const browser = await chromium.launch({ headless: true, args: launchArgs }); const context = await browser.newContext({ viewport: site.viewport ?? { width: 1366, height: 900 }, userAgent: site.userAgent ?? 'feadulta-e2e/0.1', ignoreHTTPSErrors: true, }); const results = []; for (const t of targets) { const url = site.baseUrl + t.path; const r = { slug: t.slug, url, httpStatus: null, loadMs: null, title: null, h1: null, wordCount: null, consoleErrors: [], consoleWarnings: [], pageErrors: [], failedRequests: [], brokenImages: [], imageCount: 0, screenshot: null, error: null, }; const page = await context.newPage(); const failedReqs = []; page.on('console', msg => { const type = msg.type(); const text = msg.text(); if (type === 'error') r.consoleErrors.push(text); else if (type === 'warning') r.consoleWarnings.push(text); }); page.on('pageerror', err => r.pageErrors.push(String(err))); page.on('requestfailed', req => failedReqs.push({ url: req.url(), method: req.method(), failure: req.failure()?.errorText ?? null, resourceType: req.resourceType(), })); page.on('response', resp => { const st = resp.status(); if (st >= 400) failedReqs.push({ url: resp.url(), method: resp.request().method(), status: st, resourceType: resp.request().resourceType(), }); }); const t0 = Date.now(); try { const resp = await page.goto(url, { waitUntil: 'networkidle', timeout: site.timeoutMs ?? 30000 }); r.loadMs = Date.now() - t0; r.httpStatus = resp ? resp.status() : null; r.title = await page.title(); r.h1 = await page.locator('h1').first().innerText({ timeout: 2000 }).catch(() => null); const stats = await page.evaluate(() => { const imgs = Array.from(document.images); const broken = imgs .filter(img => img.complete && img.naturalWidth === 0) .map(img => ({ src: img.currentSrc || img.src, alt: img.alt || '' })); const text = document.body?.innerText || ''; return { imageCount: imgs.length, broken, wordCount: text.trim().split(/\s+/).filter(Boolean).length, }; }); r.imageCount = stats.imageCount; r.brokenImages = stats.broken; r.wordCount = stats.wordCount; const shotPath = path.join(outDir, `${t.slug}.png`); await page.screenshot({ path: shotPath, fullPage: true }); r.screenshot = path.relative(outDir, shotPath); } catch (e) { r.error = String(e?.message ?? e); } r.failedRequests = failedReqs; await page.close(); results.push(r); const flag = severity(r); console.log(` [${flag}] ${t.slug.padEnd(24)} ${r.httpStatus ?? '---'} imgs=${r.imageCount} broken=${r.brokenImages.length} 4xx/5xx=${r.failedRequests.length} consoleErr=${r.consoleErrors.length}`); } await context.close(); await browser.close(); function severity(r) { if (r.error) return 'FAIL'; if (r.httpStatus && r.httpStatus >= 400) return 'FAIL'; if (r.brokenImages.length > 0) return 'WARN'; if (r.failedRequests.some(f => f.resourceType === 'image' || f.resourceType === 'media' || f.resourceType === 'document')) return 'WARN'; if (r.consoleErrors.length > 0) return 'WARN'; return ' OK '; } const report = { site: siteName, baseUrl: site.baseUrl, ranAt: new Date().toISOString(), results, }; fs.writeFileSync(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2)); fs.writeFileSync(path.join(outDir, 'report.md'), renderMd(report)); // symlink latest const latest = path.join(ROOT, 'out', siteName, 'latest'); try { fs.unlinkSync(latest); } catch {} try { fs.symlinkSync(ts, latest); } catch {} console.log(`\n[e2e] report: ${path.join(outDir, 'report.md')}`); function renderMd(rep) { const lines = []; lines.push(`# E2E report — ${rep.site}`); lines.push(`- baseUrl: ${rep.baseUrl}`); lines.push(`- ranAt: ${rep.ranAt}`); lines.push(''); lines.push('| Slug | Sev | HTTP | imgs | broken | 4xx/5xx | console err | load ms |'); lines.push('|---|---|---:|---:|---:|---:|---:|---:|'); for (const r of rep.results) { lines.push(`| ${r.slug} | ${severity(r).trim()} | ${r.httpStatus ?? '-'} | ${r.imageCount} | ${r.brokenImages.length} | ${r.failedRequests.length} | ${r.consoleErrors.length} | ${r.loadMs ?? '-'} |`); } lines.push(''); for (const r of rep.results) { if (severity(r).trim() === 'OK') continue; lines.push(`## ${r.slug} — ${severity(r).trim()}`); lines.push(`- URL: ${r.url}`); lines.push(`- HTTP: ${r.httpStatus} · title: ${JSON.stringify(r.title)} · h1: ${JSON.stringify(r.h1)}`); if (r.error) lines.push(`- ERROR: \`${r.error}\``); if (r.brokenImages.length) { lines.push(`- broken images (${r.brokenImages.length}):`); for (const b of r.brokenImages.slice(0, 20)) lines.push(` - \`${b.src}\` ${b.alt ? '— '+b.alt : ''}`); if (r.brokenImages.length > 20) lines.push(` - … (+${r.brokenImages.length - 20} más)`); } if (r.failedRequests.length) { lines.push(`- failed requests (${r.failedRequests.length}):`); for (const f of r.failedRequests.slice(0, 20)) lines.push(` - ${f.status ?? f.failure} ${f.resourceType} \`${f.url}\``); if (r.failedRequests.length > 20) lines.push(` - … (+${r.failedRequests.length - 20} más)`); } if (r.consoleErrors.length) { lines.push(`- console errors (${r.consoleErrors.length}):`); for (const c of r.consoleErrors.slice(0, 10)) lines.push(` - \`${c.slice(0, 200)}\``); } if (r.pageErrors.length) { lines.push(`- page errors:`); for (const c of r.pageErrors.slice(0, 5)) lines.push(` - \`${c.slice(0, 200)}\``); } lines.push(''); } return lines.join('\n') + '\n'; }