fix(content) + tools(e2e): rutas de imagen Joomla + suite E2E
- 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>
This commit is contained in:
@@ -11,6 +11,8 @@ wordpress/wp-content/languages/
|
|||||||
|
|
||||||
# Joomla original
|
# Joomla original
|
||||||
joomla/
|
joomla/
|
||||||
|
joomla-php83/
|
||||||
|
joomla-php83-old-local-20260525/
|
||||||
|
|
||||||
# Backups pesados
|
# Backups pesados
|
||||||
backup/
|
backup/
|
||||||
@@ -37,3 +39,7 @@ __pycache__/
|
|||||||
|
|
||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# E2E suite — deps + run output
|
||||||
|
tools/e2e/node_modules/
|
||||||
|
tools/e2e/out/
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fix_image_paths.php
|
||||||
|
*
|
||||||
|
* Reescribe rutas relativas Joomla `images/...` en wp_posts.post_content
|
||||||
|
* a rutas absolutas del site `/fea/wp-content/uploads/...`, pero solo cuando
|
||||||
|
* el fichero correspondiente existe en /var/www/html/wp-content/uploads/.
|
||||||
|
*
|
||||||
|
* Cubre src= y href= con comillas dobles o simples.
|
||||||
|
* URL-decodifica antes de comprobar el filesystem (mp3 con espacios/tildes).
|
||||||
|
*
|
||||||
|
* Issue: rafa/feadulta#34
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* docker exec wordpress-web php /tmp/fix_image_paths.php --dry-run
|
||||||
|
* docker exec wordpress-web php /tmp/fix_image_paths.php # live
|
||||||
|
*/
|
||||||
|
|
||||||
|
$dry_run = in_array('--dry-run', $argv ?? []);
|
||||||
|
|
||||||
|
$db_host = 'wordpress-mysql';
|
||||||
|
$db_name = 'wordpress_db';
|
||||||
|
$db_user = 'wordpress_user';
|
||||||
|
$db_pass = 'wordpress_pass';
|
||||||
|
|
||||||
|
$uploads_fs = '/var/www/html/wp-content/uploads';
|
||||||
|
$uploads_url = '/fea/wp-content/uploads';
|
||||||
|
|
||||||
|
$pdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8mb4", $db_user, $db_pass, [
|
||||||
|
PDO::ATTR_ERRMODE => 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";
|
||||||
@@ -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/<site>/<timestamp>/
|
||||||
|
report.json # datos crudos
|
||||||
|
report.md # tabla legible
|
||||||
|
<slug>.png # screenshot full-page por URL
|
||||||
|
out/<site>/latest -> <timestamp> # symlink al último run
|
||||||
|
```
|
||||||
|
|
||||||
|
Métricas por URL: HTTP status, console errors/warnings, page errors, requests fallidas (≥400), `<img>` con `naturalWidth==0`, `<title>`, `<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`.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Generated
+59
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user