#!/usr/bin/env node /* * Checklist de QA de staging (fase 13), ejecutable y repetible. * * NO es la suite e2e: aquella siembra la base (borra colecciones) y por eso su * configuración se niega a apuntar a staging. Esto es lo contrario — solo mira, * y con `--write` además crea UNA cuenta y UNA zona, que es lo que la fase pide * probar a mano. No toca datos de nadie más. * * node e2e/staging-qa.mjs # solo lectura * node e2e/staging-qa.mjs --write # + registro y alta/baja de zona * QA_URL=https://testfuegos.comunes.org node e2e/staging-qa.mjs * * Cada comprobación imprime OK/FALLO y, al final, un resumen. Las capturas de * los fallos quedan en e2e/qa-shots/. */ import fs from 'node:fs'; import path from 'node:path'; import { chromium } from '@playwright/test'; const BASE = (process.env.QA_URL || 'https://testfuegos.comunes.org').replace(/\/$/, ''); const WRITE = process.argv.includes('--write'); const SHOTS = path.resolve(import.meta.dirname, 'qa-shots'); // Comparación de host exacta, no un `endsWith`: testfuegos.comunes.org también // termina en fuegos.comunes.org y es justo donde SÍ hay que ejecutar esto. const PROD_HOSTS = ['fuegos.comunes.org', 'fires.comunes.org']; if (PROD_HOSTS.includes(new URL(BASE).hostname)) { console.error(`Me niego a hacer QA contra producción (${BASE}).`); process.exit(1); } const results = []; let page; const check = async (name, fn) => { try { const detail = await fn(); results.push({ name, ok: true, detail }); console.log(` OK ${name}${detail ? ` — ${detail}` : ''}`); } catch (e) { results.push({ name, ok: false, detail: e.message }); console.log(` FALLO ${name} — ${e.message}`); try { fs.mkdirSync(SHOTS, { recursive: true }); await page.screenshot({ path: path.join(SHOTS, `${name.replace(/[^a-z0-9]+/gi, '-')}.png`), fullPage: true }); } catch (_) { /* la captura es un extra */ } } }; const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; const visible = async (selector, msg) => { const el = page.locator(selector).first(); await el.waitFor({ state: 'visible', timeout: 30000 }).catch(() => { throw new Error(msg || `no aparece ${selector}`); }); return el; }; const mapReady = async () => { await visible('.leaflet-container', 'el mapa no llegó a montarse'); await page.waitForTimeout(2500); const tiles = await page.locator('.leaflet-tile-loaded').count(); assert(tiles > 0, 'el mapa monta pero no carga ninguna tesela'); return `${tiles} teselas`; }; const subscriptionNames = () => page.evaluate(() => (window.Meteor ? Object.values(window.Meteor.connection._subscriptions).map(s => s.name) : [])); const main = async () => { console.log(`QA de ${BASE}${WRITE ? ' (con escritura)' : ' (solo lectura)'}\n`); const browser = await chromium.launch(); const context = await browser.newContext({ locale: 'es-ES' }); page = await context.newPage(); const consoleErrors = []; page.on('pageerror', e => consoleErrors.push(String(e).slice(0, 200))); console.log('— Portada y mapas'); await check('la portada carga', async () => { const res = await page.goto(BASE, { waitUntil: 'domcontentloaded' }); assert(res.ok(), `HTTP ${res.status()}`); await visible('nav'); return `HTTP ${res.status()}`; }); await check('la portada monta sus mapas', mapReady); await check('/fires pinta el mapa de fuegos activos', async () => { await page.goto(`${BASE}/fires`, { waitUntil: 'domcontentloaded' }); return mapReady(); }); await check('/fires se suscribe a activefiresmyloc', async () => { const names = await subscriptionNames(); assert(names.includes('activefiresmyloc'), `suscripciones presentes: ${names.join(', ') || 'ninguna'}`); return names.filter(n => n.startsWith('activefires')).join(', '); }); await check('el contador de fuegos activos no dice NaN', async () => { const text = await page.locator('body').innerText(); assert(!/NaN/.test(text), 'aparece NaN en la página'); const m = text.match(/(\d+)\s+fuegos activos/i); return m ? `${m[1]} fuegos` : 'sin contador visible'; }); await check('conmuta la capa base del mapa', async () => { await page.locator('.leaflet-control-layers-toggle').first().hover(); const options = await page.locator('.leaflet-control-layers-base label').count(); assert(options >= 2, `solo ${options} capa(s) base`); await page.locator('.leaflet-control-layers-base input').nth(1).check(); await page.waitForTimeout(2000); return `${options} capas base`; }); await check('/zones pinta el mapa de zonas vigiladas', async () => { await page.goto(`${BASE}/zones`, { waitUntil: 'domcontentloaded' }); return mapReady(); }); await check('/zones pinta la unión de suscripciones', async () => { const paths = await page.locator('.leaflet-overlay-pane path').count(); assert(paths > 0, 'no hay ningún polígono en la capa de superposición'); return `${paths} polígonos`; }); console.log('\n— Detalle de fuego y comentarios'); await check('hay un fuego al que entrar', async () => { await page.goto(`${BASE}/fires`, { waitUntil: 'domcontentloaded' }); await mapReady(); const markers = await page.locator('.leaflet-marker-icon, .leaflet-interactive').count(); assert(markers > 0, 'el mapa de fuegos no muestra ninguna marca'); return `${markers} marcas`; }); await check('el detalle de un fuego abre con su mapa', async () => { await page.locator('.leaflet-interactive').first().click(); await page.waitForURL(/\/fire\//, { timeout: 30000 }); await visible('h4.page-header'); await mapReady(); return page.url().replace(BASE, ''); }); await check('el detalle ofrece comentarios', async () => { const text = await page.locator('body').innerText(); assert(/Comentarios/i.test(text), 'no aparece la sección de comentarios'); return 'sección presente'; }); console.log('\n— Idioma y cookies'); await check('el banner de cookies aparece y se acepta', async () => { const accept = page.getByRole('button', { name: /Aceptar/i }).first(); if (await accept.count() === 0) return 'ya aceptado en esta sesión'; await accept.click(); await page.waitForTimeout(500); return 'aceptado'; }); await check('un navegador en inglés ve la web en inglés', async () => { const enContext = await browser.newContext({ locale: 'en-US' }); const enPage = await enContext.newPage(); await enPage.goto(BASE, { waitUntil: 'domcontentloaded' }); await enPage.waitForTimeout(3000); const label = await enPage.locator('#moniZones').innerText().catch(() => ''); await enContext.close(); assert(/Monitored/i.test(label), `la navegación sigue en español: "${label}"`); return `#moniZones = "${label}"`; }); await check('el mismo servidor sirve español a un navegador en español', async () => { await page.goto(BASE, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2000); const label = await page.locator('#moniZones').innerText(); assert(/Zonas/i.test(label), `esperaba español, salió "${label}"`); return `#moniZones = "${label}"`; }); console.log('\n— Páginas estáticas'); for (const p of ['/about', '/terms', '/privacy', '/license', '/credits']) { // eslint-disable-next-line no-await-in-loop await check(`${p} responde`, async () => { const res = await page.goto(BASE + p, { waitUntil: 'domcontentloaded' }); assert(res.ok(), `HTTP ${res.status()}`); const text = await page.locator('body').innerText(); assert(text.length > 200, 'la página viene casi vacía'); return `${text.length} caracteres`; }); } if (WRITE) { console.log('\n— Registro y zona (escribe en staging)'); const email = `qa+${Date.now()}@invalid.test`; await check('registro de una cuenta nueva', async () => { await page.goto(`${BASE}/signup`, { waitUntil: 'domcontentloaded' }); await page.fill('input[name=firstName]', 'QA'); await page.fill('input[name=lastName]', 'Fase13'); await page.fill('input[name=emailAddress]', email); await page.fill('input[name=password]', 'password-qa-13'); await page.check('#tos'); await page.click('#signUpSubmit'); await page.waitForURL('**/subscriptions', { timeout: 60000 }); return email; }); await check('alta de una zona desde el mapa', async () => { await mapReady(); await page.getByRole('button', { name: 'Añadir zona' }).click(); await page.waitForURL('**/subscriptions/new', { timeout: 30000 }); await mapReady(); const box = await page.locator('.leaflet-container').first().boundingBox(); await page.mouse.click(box.x + (box.width / 2) + 20, box.y + (box.height / 2) + 20); await page.waitForTimeout(1000); const started = Date.now(); await page.getByRole('button', { name: /Suscribirme/ }).click(); await page.waitForURL('**/subscriptions', { timeout: 120000 }); return `${((Date.now() - started) / 1000).toFixed(1)} s en responder`; }); await check('la zona nueva aparece pintada', async () => { await mapReady(); const paths = await page.locator('.leaflet-overlay-pane path').count(); assert(paths > 0, 'no se pinta ninguna zona tras el alta'); return `${paths} polígonos`; }); await check('la web sigue respondiendo tras el alta', async () => { const started = Date.now(); await page.goto(`${BASE}/fires`, { waitUntil: 'domcontentloaded' }); await mapReady(); return `${((Date.now() - started) / 1000).toFixed(1)} s en cargar /fires`; }); await check('baja de la zona', async () => { await page.goto(`${BASE}/subscriptions`, { waitUntil: 'domcontentloaded' }); await mapReady(); await page.getByRole('button', { name: /Editar/i }).click(); await page.locator('.leaflet-marker-icon').first().click(); await page.getByRole('button', { name: 'Sí', exact: true }).click(); await visible('text=No estás suscrito a fuegos en ninguna zona', 'la zona no llegó a borrarse'); return 'borrada'; }); await check('cierre de sesión', async () => { await page.click('#logout'); await visible('#login'); return 'sesión cerrada'; }); } console.log('\n— Errores de JavaScript'); await check('ninguna página lanzó una excepción', async () => { assert(consoleErrors.length === 0, `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ')}`); return 'sin excepciones'; }); await browser.close(); const failed = results.filter(r => !r.ok); console.log(`\n${results.length - failed.length}/${results.length} comprobaciones en verde`); if (failed.length) { console.log('Fallos:'); failed.forEach(f => console.log(` - ${f.name}: ${f.detail}`)); process.exit(1); } }; main().catch((e) => { console.error(e); process.exit(1); });