From 82d0399c8630db7355b93a9ca70427f0977201ce Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 1 Aug 2026 23:17:40 +0200 Subject: [PATCH] fix(maps): el mapa de /fires se quedaba en gris cuando hay clave de Google MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- e2e/.gitignore | 1 + e2e/staging-qa.mjs | 259 +++++++++++++++++++++ e2e/tests/fires-map.spec.js | 54 +++++ imports/ui/components/Maps/DefMapLayers.js | 32 ++- settings-ci.json | 4 +- 5 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 e2e/staging-qa.mjs create mode 100644 e2e/tests/fires-map.spec.js diff --git a/e2e/.gitignore b/e2e/.gitignore index 945fcd0..100897a 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -1,3 +1,4 @@ node_modules/ playwright-report/ test-results/ +qa-shots/ diff --git a/e2e/staging-qa.mjs b/e2e/staging-qa.mjs new file mode 100644 index 0000000..d74b5f0 --- /dev/null +++ b/e2e/staging-qa.mjs @@ -0,0 +1,259 @@ +#!/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); +}); diff --git a/e2e/tests/fires-map.spec.js b/e2e/tests/fires-map.spec.js new file mode 100644 index 0000000..5ebaede --- /dev/null +++ b/e2e/tests/fires-map.spec.js @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; +import { waitForMap } from '../support/app.js'; + +// El mapa de /fires es la página principal del sitio y se quedaba EN GRIS en +// staging: ni teselas, ni fuegos, ni suscripción por viewport, con el +// "Actualizando…" eterno. Se había dado por un artefacto del navegador sin +// pantalla; no lo era. El control de capas se montaba con dos capas base y, al +// resolverse la clave de Google, se le añadían tres más — y en esa segunda +// pasada react-leaflet se dejaba por el camino la capa base marcada. +// +// Solo se veía en despliegues CON clave de Google. Por eso settings-ci.json +// lleva una clave falsa: para que esta suite recorra el mismo camino que +// staging y producción. +test.describe('mapa de fuegos activos', () => { + test('paints its tiles', async ({ page }) => { + await page.goto('/fires'); + await waitForMap(page); + + await expect(page.locator('.leaflet-tile-loaded').first()).toBeVisible({ timeout: 60000 }); + expect(await page.locator('.leaflet-tile').count()).toBeGreaterThan(0); + }); + + test('offers every base layer and keeps the checked one', async ({ page }) => { + await page.goto('/fires'); + await waitForMap(page); + + const layers = page.locator('.leaflet-control-layers-base input'); + expect(await layers.count()).toBeGreaterThanOrEqual(2); + // Exactamente una marcada, y el mapa con teselas: si la marcada se hubiera + // perdido, seguiría marcada en el control pero no habría teselas. + const checked = await page.locator('.leaflet-control-layers-base input:checked').count(); + expect(checked).toEqual(1); + expect(await page.locator('.leaflet-tile').count()).toBeGreaterThan(0); + }); + + test('subscribes to the fires of the current viewport', async ({ page }) => { + await page.goto('/fires'); + await waitForMap(page); + + const names = await page.evaluate(() => Object.values(window.Meteor.connection._subscriptions).map(s => s.name)); + expect(names).toContain('activefiresmyloc'); + expect(names).toContain('activefiresunionmyloc'); + }); + + test('does not get stuck on "Actualizando…"', async ({ page }) => { + await page.goto('/fires'); + await waitForMap(page); + await page.waitForTimeout(5000); + + // El texto aparece mientras la suscripción no está lista; quedarse ahí es + // justo el síntoma del mapa gris. + await expect(page.getByText('Actualizando...', { exact: false })).toHaveCount(0); + }); +}); diff --git a/imports/ui/components/Maps/DefMapLayers.js b/imports/ui/components/Maps/DefMapLayers.js index 022f298..cc76919 100644 --- a/imports/ui/components/Maps/DefMapLayers.js +++ b/imports/ui/components/Maps/DefMapLayers.js @@ -13,23 +13,45 @@ const { BaseLayer } = LayersControl; const defOpacity = 0.7; +// Cuánto se espera a que Google conteste antes de montar el control solo con +// OpenStreetMap. Si el script de Google no llega (sin red hacia Google, una +// extensión que lo bloquea, un despliegue sin clave), el mapa tiene que salir +// igual: las capas de Google son un extra. +const GMAPS_WAIT_MS = 5000; + class DefMapLayers extends Component { constructor(props) { super(props); this.state = { - gkey: null + gkey: null, + // El control de capas se monta UNA vez, con la lista de capas ya cerrada. + // Añadirle capas después —que es lo que pasaba al resolverse la clave de + // Google— hacía que react-leaflet se dejara por el camino la capa base + // marcada, y el mapa se quedaba en gris: sin teselas, sin fuegos y con el + // "Actualizando…" eterno. Solo se veía en despliegues CON clave de Google + // (staging y producción), nunca en desarrollo, que no la tiene. + ready: false }; } componentDidMount() { - const self = this; - Gkeys.load((err, key) => { - self.setState({ gkey: key }); - }); + this.mounted = true; + const settle = (key) => { + if (!this.mounted || this.state.ready) return; + this.setState({ gkey: key || null, ready: true }); + }; + Gkeys.load((err, key) => settle(key)); + this.waitTimer = setTimeout(() => settle(null), GMAPS_WAIT_MS); + } + + componentWillUnmount() { + this.mounted = false; + clearTimeout(this.waitTimer); } render() { const { t } = this.props; + if (!this.state.ready) return null; const osmgraylayer = (