diff --git a/.gitignore b/.gitignore index 53effbb..2a1d0af 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ wordpress/wp-content/languages/ # Joomla original joomla/ +joomla-php83/ +joomla-php83-old-local-20260525/ # Backups pesados backup/ @@ -37,3 +39,7 @@ __pycache__/ # Claude Code local settings .claude/ + +# E2E suite — deps + run output +tools/e2e/node_modules/ +tools/e2e/out/ diff --git a/scripts/fix_image_paths.php b/scripts/fix_image_paths.php new file mode 100644 index 0000000..5dc03ac --- /dev/null +++ b/scripts/fix_image_paths.php @@ -0,0 +1,137 @@ + PDO::ERRMODE_EXCEPTION, +]); + +echo "=== Fix image paths (Joomla `images/...` → WP uploads) ===\n"; +echo $dry_run ? "[DRY RUN]\n\n" : "[LIVE RUN]\n\n"; + +$stmt = $pdo->query(" + SELECT ID, post_title, post_content + FROM wp_posts + WHERE post_status IN ('publish','draft') + AND post_type IN ('post','page') + AND ( + post_content LIKE '%src=\"images/%' + OR post_content LIKE \"%src='images/%\" + OR post_content LIKE '%href=\"images/%' + OR post_content LIKE \"%href='images/%\" + ) +"); +$posts = $stmt->fetchAll(PDO::FETCH_ASSOC); +echo "Posts candidatos: " . count($posts) . "\n\n"; + +$stats = [ + 'posts_changed' => 0, + 'posts_unchanged' => 0, + 'refs_rewritten' => 0, + 'refs_missing_file' => 0, +]; +$missing = []; // path => count +$missing_per_post = []; // ID => [path,...] + +// (src|href)= ( " | ' ) images/... ( " | ' ) +$pattern = '/\b(src|href)=("|\')images\/([^"\']+)\2/i'; + +$update = $pdo->prepare("UPDATE wp_posts SET post_content = ? WHERE ID = ?"); + +foreach ($posts as $post) { + $original = $post['post_content']; + $pid = (int)$post['ID']; + + $content = preg_replace_callback( + $pattern, + function ($m) use ($uploads_fs, $uploads_url, &$stats, &$missing, &$missing_per_post, $pid) { + $attr = $m[1]; + $quote = $m[2]; + $rel_enc = $m[3]; // tal como aparece en HTML (puede ir URL-encoded) + $rel_dec = urldecode($rel_enc); // para mirar el filesystem + $fs_path = $uploads_fs . '/' . $rel_dec; + + if (is_file($fs_path)) { + $stats['refs_rewritten']++; + return $attr . '=' . $quote . $uploads_url . '/' . $rel_enc . $quote; + } + + $stats['refs_missing_file']++; + $missing[$rel_dec] = ($missing[$rel_dec] ?? 0) + 1; + $missing_per_post[$pid][] = $rel_dec; + return $m[0]; // dejar sin tocar + }, + $original + ); + + if ($content !== $original) { + $stats['posts_changed']++; + if (!$dry_run) { + $update->execute([$content, $pid]); + } + } else { + $stats['posts_unchanged']++; + } +} + +echo "=== Resumen ===\n"; +echo "Posts modificados: {$stats['posts_changed']}\n"; +echo "Posts sin cambios: {$stats['posts_unchanged']}\n"; +echo "Referencias reescritas: {$stats['refs_rewritten']}\n"; +echo "Referencias sin fichero: {$stats['refs_missing_file']}\n"; +echo "Rutas faltantes únicas: " . count($missing) . "\n"; + +if (!empty($missing)) { + arsort($missing); + $log_path = '/tmp/fix_image_paths_missing.log'; + $lines = []; + foreach ($missing as $path => $n) { + $lines[] = sprintf("%4d %s", $n, $path); + } + file_put_contents($log_path, implode("\n", $lines) . "\n"); + echo "\nLog rutas faltantes (orden por #ocurrencias): $log_path\n"; + echo "Top 15:\n"; + $i = 0; + foreach ($missing as $path => $n) { + echo sprintf(" %4d %s\n", $n, $path); + if (++$i >= 15) break; + } + + // breakdown por carpeta raíz (segmento tras `images/`) + $by_root = []; + foreach ($missing as $path => $n) { + $root = explode('/', $path)[0] ?? '?'; + $by_root[$root] = ($by_root[$root] ?? 0) + $n; + } + arsort($by_root); + echo "\nFaltantes por carpeta raíz:\n"; + foreach ($by_root as $root => $n) { + echo sprintf(" %4d images/%s/\n", $n, $root); + } +} + +echo "\nHecho.\n"; diff --git a/tools/e2e/README.md b/tools/e2e/README.md new file mode 100644 index 0000000..cdd266c --- /dev/null +++ b/tools/e2e/README.md @@ -0,0 +1,101 @@ +# tools/e2e — Suite E2E económica en tokens + +Pipeline de revisión visual/funcional con coste mínimo en tokens de Claude. Diseño 3-tier: + +| Tier | Quién | Coste tokens Claude | +|---|---|---| +| 1 — Programático | Playwright local | **0** | +| 2 — LLM local vision | Gemma 4 vision en LM Studio | 0 | +| 3 — Claude | Lee `report.md` (texto) | Mínimo | + +Issue de diseño: rafa/feadulta#37. + +## Setup + +```bash +cd tools/e2e +npm install # instala playwright 1.58.2 (reaprovecha browsers ya en ~/.cache/ms-playwright/) +``` + +## Uso — Tier 1 (sin LLM) + +```bash +# Suite completa +node run.js feadulta + +# Una sola URL por slug +node run.js feadulta home-es + +# Subconjunto +node run.js feadulta --only=home-es,effa-hub,carta-semana +``` + +Genera por cada pasada: + +``` +out/// + report.json # datos crudos + report.md # tabla legible + .png # screenshot full-page por URL +out//latest -> # symlink al último run +``` + +Métricas por URL: HTTP status, console errors/warnings, page errors, requests fallidas (≥400), `` con `naturalWidth==0`, ``, `<h1>`, word count, tiempo de carga. + +Severity: +- `OK` — sin problemas detectados +- `WARN` — imágenes rotas, requests 4xx, console errors +- `FAIL` — HTTP ≥ 400 o error de navegación + +## Uso — Tier 2 (vision Gemma 4) + +```bash +# Pregunta ad hoc sobre una captura +node ask-vision.js out/feadulta/latest/post-tolle-44247.png \ + "¿Se ve la foto de Eckhart Tolle? ¿Hay placeholders rotos?" + +# Auto: pasa cada captura WARN/FAIL a la vision y escribe vision.md +node ask-vision.js --auto out/feadulta/latest +``` + +Variables de entorno (opcionales): +- `LMSTUDIO_URL` — default `http://172.19.128.1:1234/v1` (gateway WSL→Windows) +- `LMSTUDIO_MODEL` — default `google/gemma-4-e4b` +- `MAX_TOKENS` — default 800 +- `TEMPERATURE` — default 0.2 + +Requisito: LM Studio cargado con un modelo multimodal y escuchando en `0.0.0.0:1234`. + +## Añadir un sitio + +Crear `sites/<nombre>.json`: + +```json +{ + "name": "ytsummaries", + "baseUrl": "https://farmer.taild3aaf6.ts.net", + "viewport": { "width": 1366, "height": 900 }, + "timeoutMs": 30000, + "userAgent": "ytsummaries-e2e/0.1", + "urls": [ + { "slug": "home", "path": "/yt/" }, + { "slug": "admin", "path": "/yt/wp-admin/" } + ] +} +``` + +`baseUrl + path` se concatena tal cual. Ejecutar con `node run.js ytsummaries`. + +## Diseño + +- **Sin LLM por defecto:** los checks deterministas (HTTP/console/`naturalWidth`) cubren los fallos reales. +- **Vision bajo demanda:** Gemma 4 solo entra cuando hace falta opinar sobre maquetación o cuando Tier 1 marca algo raro. +- **Claude solo lee texto:** `report.md` cabe en ~10 KB. Las PNG no entran al contexto salvo que el usuario lo pida. +- **Stateless:** cada pasada vive en su propia carpeta timestamp. `latest` es symlink. +- **Reutilizable:** el runner no sabe nada de feadulta; solo conoce el formato `sites/<x>.json`. + +## Limitaciones conocidas + +- Gemma 4 a veces escribe razonamiento antes de la respuesta final. `ask-vision.js` intenta extraer "Final Answer:" o la primera etiqueta `[OK]/[WARN]/[FAIL]`. +- No hay comparación con baseline aún. El runner solo describe el estado actual. Snapshot/pHash es trivial de añadir cuando se quiera detección de regresiones contra una pasada anterior. +- Playwright 1.58.2 está pineado para reaprovechar los browsers ya cacheados (`chromium_headless_shell-1208`). Subir a 1.60+ requiere `npx playwright install`. diff --git a/tools/e2e/ask-vision.js b/tools/e2e/ask-vision.js new file mode 100644 index 0000000..0f671d9 --- /dev/null +++ b/tools/e2e/ask-vision.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// Tier 2: pregunta a Gemma 4 vision (LM Studio) sobre una screenshot. +// +// Uso: +// node ask-vision.js <ruta/screenshot.png> "<prompt en español>" +// cat report.json | node ask-vision.js --auto out/feadulta/latest +// → para cada result con severity != OK, pregunta a la vision y añade +// el resultado a out/feadulta/latest/vision.json + vision.md +// +// Env vars opcionales: +// LMSTUDIO_URL default http://172.19.128.1:1234/v1 +// LMSTUDIO_MODEL default google/gemma-4-e4b +// MAX_TOKENS default 800 +// TEMPERATURE default 0.2 + +import fs from 'node:fs'; +import path from 'node:path'; + +const LMSTUDIO_URL = process.env.LMSTUDIO_URL || 'http://172.19.128.1:1234/v1'; +const LMSTUDIO_MODEL = process.env.LMSTUDIO_MODEL || 'google/gemma-4-e4b'; +const MAX_TOKENS = Number(process.env.MAX_TOKENS || 800); +const TEMPERATURE = Number(process.env.TEMPERATURE || 0.2); + +const DEFAULT_PROMPT = + 'Eres un revisor visual de páginas web. Describe brevemente en español qué ves en la captura: ' + + 'estructura general (cabecera, contenido, pie), si hay imágenes rotas (placeholders, iconos de imagen rota, espacios vacíos donde debería haber contenido), ' + + 'si el texto se ve bien (no solapado, no recortado), y cualquier elemento claramente fuera de sitio. ' + + 'Sé conciso (máximo 6 frases). Empieza la respuesta con una de estas etiquetas: [OK], [WARN] o [FAIL].'; + +async function ask(screenshotPath, prompt = DEFAULT_PROMPT) { + if (!fs.existsSync(screenshotPath)) throw new Error(`No existe ${screenshotPath}`); + const buf = fs.readFileSync(screenshotPath); + const b64 = buf.toString('base64'); + + const body = { + model: LMSTUDIO_MODEL, + messages: [{ + role: 'user', + content: [ + { type: 'text', text: prompt }, + { type: 'image_url', image_url: { url: `data:image/png;base64,${b64}` } }, + ], + }], + max_tokens: MAX_TOKENS, + temperature: TEMPERATURE, + }; + + const t0 = Date.now(); + const resp = await fetch(`${LMSTUDIO_URL}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!resp.ok) throw new Error(`LM Studio ${resp.status}: ${await resp.text()}`); + const out = await resp.json(); + const dt = Date.now() - t0; + + const raw = out.choices?.[0]?.message?.content ?? ''; + const text = extractFinal(raw); + return { + screenshot: screenshotPath, + text, + raw, + model: LMSTUDIO_MODEL, + elapsedMs: dt, + usage: out.usage, + }; +} + +function extractFinal(s) { + // Gemma 4 a veces escribe razonamiento antes de la respuesta. Buscamos marcadores. + const m = s.match(/Final Answer[^:]*:\s*([\s\S]*)$/i); + if (m) return m[1].trim(); + // O coge desde la primera etiqueta de severidad + const sev = s.match(/\[(?:OK|WARN|FAIL)\][\s\S]*$/); + if (sev) return sev[0].trim(); + return s.trim(); +} + +async function modeAuto(reportDir) { + const jsonPath = path.join(reportDir, 'report.json'); + const report = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + const sevs = report.results.filter(r => sevTag(r) !== 'OK'); + console.log(`[vision] ${sevs.length}/${report.results.length} candidatos`); + const results = []; + for (const r of sevs) { + if (!r.screenshot) continue; + const shot = path.join(reportDir, r.screenshot); + console.log(` → ${r.slug}`); + try { + const v = await ask(shot); + results.push({ slug: r.slug, severity: sevTag(r), ...v }); + } catch (e) { + results.push({ slug: r.slug, severity: sevTag(r), error: String(e?.message ?? e) }); + } + } + fs.writeFileSync(path.join(reportDir, 'vision.json'), JSON.stringify(results, null, 2)); + const md = ['# Vision check', '']; + for (const v of results) { + md.push(`## ${v.slug} — ${v.severity}`); + md.push(''); + md.push(v.text ?? `_error: ${v.error}_`); + md.push(''); + } + fs.writeFileSync(path.join(reportDir, 'vision.md'), md.join('\n')); + console.log(`[vision] vision.md escrito en ${reportDir}`); +} + +function sevTag(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 args = process.argv.slice(2); +if (args[0] === '--auto') { + await modeAuto(args[1]); +} else if (args.length >= 1) { + const [shot, prompt] = args; + const v = await ask(shot, prompt); + console.log(`[${v.elapsedMs} ms · ${v.usage?.total_tokens ?? '?'} tk]\n`); + console.log(v.text); +} else { + console.error('Uso:'); + console.error(' node ask-vision.js <screenshot.png> "<prompt>"'); + console.error(' node ask-vision.js --auto <report_dir>'); + process.exit(2); +} diff --git a/tools/e2e/package-lock.json b/tools/e2e/package-lock.json new file mode 100644 index 0000000..bd32931 --- /dev/null +++ b/tools/e2e/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "feadulta-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "feadulta-e2e", + "version": "0.1.0", + "dependencies": { + "playwright": "1.58.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/tools/e2e/package.json b/tools/e2e/package.json new file mode 100644 index 0000000..61c628f --- /dev/null +++ b/tools/e2e/package.json @@ -0,0 +1,14 @@ +{ + "name": "feadulta-e2e", + "version": "0.1.0", + "description": "E2E suite for feadulta WP local (Tier 1 Playwright + Tier 2 LM Studio vision)", + "private": true, + "type": "module", + "scripts": { + "run": "node run.js", + "vision": "node ask-vision.js" + }, + "dependencies": { + "playwright": "1.58.2" + } +} diff --git a/tools/e2e/run.js b/tools/e2e/run.js new file mode 100644 index 0000000..f5579c1 --- /dev/null +++ b/tools/e2e/run.js @@ -0,0 +1,228 @@ +#!/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'; +} diff --git a/tools/e2e/sites/feadulta.json b/tools/e2e/sites/feadulta.json new file mode 100644 index 0000000..a714eb1 --- /dev/null +++ b/tools/e2e/sites/feadulta.json @@ -0,0 +1,22 @@ +{ + "name": "feadulta", + "baseUrl": "https://farmer.taild3aaf6.ts.net", + "viewport": { "width": 1366, "height": 900 }, + "timeoutMs": 30000, + "userAgent": "feadulta-e2e/0.1 (+local)", + "urls": [ + { "slug": "home-es", "path": "/fea/" }, + { "slug": "home-en", "path": "/fea/en/" }, + { "slug": "home-fr", "path": "/fea/fr/" }, + { "slug": "home-it", "path": "/fea/it/" }, + { "slug": "home-pt", "path": "/fea/pt/" }, + { "slug": "effa-hub", "path": "/fea/escuela/" }, + { "slug": "carta-semana", "path": "/fea/carta-de-la-semana/" }, + { "slug": "evangelios", "path": "/fea/category/evangelios-y-comentarios/" }, + { "slug": "autores", "path": "/fea/autores-lista/" }, + { "slug": "post-tolle-44247", "path": "/fea/el-apego-a-tus-puntos-de-vista-y-opiniones/" }, + { "slug": "post-delta-44117", "path": "/fea/?p=44117" }, + { "slug": "post-delta-44131", "path": "/fea/?p=44131" }, + { "slug": "post-delta-44241", "path": "/fea/?p=44241" } + ] +}