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.
259 lines
11 KiB
JavaScript
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);
|
|
});
|