56ee1580b0
- scripts/fix_image_paths.php: reescribe src/href "images/..." en wp_posts a "/fea/wp-content/uploads/..." cuando el fichero existe en uploads. Cubre comillas dobles/simples, urldecode antes de chequear filesystem. Resuelto contra issue #34 (458 posts, 465 refs) y completado con #36 (124 posts, 128 refs tras recuperar 127 assets del backup producción). - tools/e2e/: pipeline 3-tier para validar WP local con coste mínimo en tokens de Claude — Playwright (Tier 1, deterministas) + Gemma 4 vision en LM Studio (Tier 2, bajo demanda) + Claude solo lee report.md. Issue de diseño #37, suite inicial en sites/feadulta.json (13 URLs). - .gitignore: excluir tools/e2e/node_modules y out/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
229 lines
7.6 KiB
JavaScript
229 lines
7.6 KiB
JavaScript
#!/usr/bin/env node
|
|
// Tier 1: Playwright + checks deterministas. Sin LLM.
|
|
//
|
|
// Uso:
|
|
// node run.js <site> # corre toda la suite de sites/<site>.json
|
|
// node run.js <site> <slug> # corre solo una URL
|
|
// node run.js <site> --only=home-es,effa-hub
|
|
//
|
|
// Genera:
|
|
// out/<site>/<timestamp>/<slug>.png
|
|
// out/<site>/<timestamp>/report.json
|
|
// out/<site>/<timestamp>/report.md
|
|
// out/<site>/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 <site> [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}`);
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
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';
|
|
}
|