todos-contra-el-fuego-web/e2e/staging-qa.mjs
vjrj 82d0399c86
All checks were successful
build-image / test (push) Successful in 3m15s
build-image / build (push) Successful in 13m51s
fix(maps): el mapa de /fires se quedaba en gris cuando hay clave de Google
Síntoma: en staging /fires no pintaba teselas ni fuegos, no creaba la
suscripción `activefiresmyloc` y se quedaba con "Actualizando…" para siempre. Se
había atribuido a que el navegador de las pruebas no compone; no era eso. El
mismo navegador pinta la portada, y pinta /fires perfectamente contra un
servidor local.

La diferencia era la clave de Google. `DefMapLayers` montaba el control de capas
con dos capas base (OSM color y gris) y, cuando `Gkeys` resolvía la clave,
volvía a renderizar añadiendo tres capas de Google. En esa segunda pasada
react-leaflet se dejaba por el camino la capa base marcada, y el mapa se quedaba
sin ninguna. En desarrollo no hay clave, así que la segunda pasada no añadía
nada y el bug no se veía nunca.

El control se monta ahora una sola vez, con la lista de capas ya cerrada, y con
un tope de 5 s por si el script de Google no llega: las capas de Google son un
extra, el mapa tiene que salir igual.

Reproducido en local poniendo una clave falsa en settings-ci.json (0 teselas
antes del arreglo, 15 después). Esa clave falsa se queda ahí a propósito, para
que la suite e2e recorra el mismo camino que staging y producción, y
e2e/tests/fires-map.spec.js lo fija: teselas, una única capa base marcada,
suscripción por viewport, y que no se quede en "Actualizando…".

16 tests e2e en verde.
2026-08-01 23:17:50 +02:00

259 lines
11 KiB
JavaScript

#!/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);
});