From b0ca838788b9a33d87eebc6368cd1e9f7c8a2f42 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sun, 2 Aug 2026 14:50:39 +0200 Subject: [PATCH] =?UTF-8?q?test(e2e):=20afinar=20el=20diagn=C3=B3stico=20d?= =?UTF-8?q?el=20mapa=20gris=20=E2=80=94=20no=20es=20DefMapLayers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /zones y /fires usan el mismo componente con las mismas props y en staging uno pinta 18 teselas y el otro cero, con el mapa inicializado y con vista en ambos. Hay que mirar cómo FiresMap monta los hijos del MapContainer, no el componente de capas. Y el detalle de fuego pide `satellite`, una capa de Google que ni existe hasta que se resuelve la clave. Además el script de QA deja de usar selectores por id: TestUtils.testId() devuelve el id SOLO en desarrollo, así que en staging y producción no existen — cualquier prueba de navegador escrita con ids pasa en local y falla en staging. --- e2e/staging-qa.mjs | 104 ++++++++++++++++++++++++------------ e2e/tests/fires-map.spec.js | 15 ++++-- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/e2e/staging-qa.mjs b/e2e/staging-qa.mjs index d74b5f0..ffe48ca 100644 --- a/e2e/staging-qa.mjs +++ b/e2e/staging-qa.mjs @@ -21,6 +21,9 @@ import { chromium } from '@playwright/test'; const BASE = (process.env.QA_URL || 'https://testfuegos.comunes.org').replace(/\/$/, ''); const WRITE = process.argv.includes('--write'); +// Id hexadecimal de un activefire para probar la página de detalle. Se saca en +// solo lectura del propio staging; sin él esa parte se salta. +const FIRE = process.env.QA_FIRE || ''; const SHOTS = path.resolve(import.meta.dirname, 'qa-shots'); // Comparación de host exacta, no un `endsWith`: testfuegos.comunes.org también @@ -31,6 +34,19 @@ if (PROD_HOSTS.includes(new URL(BASE).hostname)) { process.exit(1); } +// ⚠️ NADA de selectores por id: `imports/ui/components/Utils/TestUtils.js` +// devuelve el id solo en desarrollo, así que en staging y producción los +// elementos NO lo llevan. Aquí se va por href, rol y texto, que es lo que hay +// en los tres sitios. +const NAV = { + zonas: 'nav a[href="/zones"]', + fuegos: 'nav a[href="/fires"]', + perfil: 'nav a[href="/profile"]', + entrar: 'nav a[href="/login"]', + salir: 'nav a[href="/logout"]', + misZonas: 'nav a[href="/subscriptions"]' +}; + const results = []; let page; @@ -51,6 +67,17 @@ const check = async (name, fn) => { const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; +// La página es una SPA: tras `domcontentloaded` el body está casi vacío hasta +// que React pinta. Sin esperar a esto, medio checklist daba falsos fallos. +const rendered = async (minChars = 400) => { + await page.waitForFunction( + n => document.body && document.body.innerText.trim().length > n, + minChars, + { timeout: 45000 } + ).catch(() => { throw new Error('la página no llegó a pintarse'); }); + return (await page.locator('body').innerText()).length; +}; + const visible = async (selector, msg) => { const el = page.locator(selector).first(); await el.waitFor({ state: 'visible', timeout: 30000 }).catch(() => { @@ -84,6 +111,7 @@ const main = async () => { const res = await page.goto(BASE, { waitUntil: 'domcontentloaded' }); assert(res.ok(), `HTTP ${res.status()}`); await visible('nav'); + await rendered(); return `HTTP ${res.status()}`; }); await check('la portada monta sus mapas', mapReady); @@ -104,10 +132,13 @@ const main = async () => { 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(); + // Hover sobre el contenedor, no sobre el botón: al desplegarse, la lista se + // pone encima del botón y le roba el puntero. + await page.locator('.leaflet-control-layers').first().hover(); + await page.waitForTimeout(500); 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.locator('.leaflet-control-layers-base input').nth(1).check({ force: true }); await page.waitForTimeout(2000); return `${options} capas base`; }); @@ -123,25 +154,28 @@ const main = async () => { }); 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'; - }); + // Por id, no clicando una marca del mapa: mientras /fires siga en gris, ese + // camino no existe, y el detalle del fuego se puede probar igualmente. + if (!FIRE) { + console.log(' (saltado) sin QA_FIRE= no se puede abrir un detalle'); + } else { + await check('el detalle de un fuego abre con su mapa', async () => { + await page.goto(`${BASE}/fire/active/${FIRE}`, { waitUntil: 'domcontentloaded' }); + await visible('h4.page-header'); + await rendered(); + await mapReady(); + return page.url().replace(BASE, ''); + }); + await check('el detalle canonicaliza la URL al archivo', async () => { + assert(/\/fire\/archive\/[0-9a-f]{24}$/.test(page.url()), `URL inesperada: ${page.url()}`); + 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 () => { @@ -155,18 +189,21 @@ const main = 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 enPage.waitForFunction((sel) => { + const el = document.querySelector(sel); + return el && el.textContent.trim().length > 0; + }, NAV.zonas, { timeout: 45000 }).catch(() => {}); + const label = await enPage.locator(NAV.zonas).innerText().catch(() => ''); await enContext.close(); assert(/Monitored/i.test(label), `la navegación sigue en español: "${label}"`); - return `#moniZones = "${label}"`; + return `enlace de zonas = "${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(); + await rendered(); + const label = await page.locator(NAV.zonas).innerText(); assert(/Zonas/i.test(label), `esperaba español, salió "${label}"`); - return `#moniZones = "${label}"`; + return `enlace de zonas = "${label}"`; }); console.log('\n— Páginas estáticas'); @@ -175,9 +212,8 @@ const main = async () => { 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`; + const chars = await rendered(600); + return `${chars} caracteres`; }); } @@ -190,8 +226,8 @@ const main = async () => { 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.check('input[name=tos]'); + await page.getByRole('button', { name: 'Registrarse', exact: true }).click(); await page.waitForURL('**/subscriptions', { timeout: 60000 }); return email; }); @@ -230,8 +266,8 @@ const main = async () => { return 'borrada'; }); await check('cierre de sesión', async () => { - await page.click('#logout'); - await visible('#login'); + await page.locator(NAV.salir).click(); + await visible(NAV.entrar, 'no vuelve a aparecer el enlace de iniciar sesión'); return 'sesión cerrada'; }); } diff --git a/e2e/tests/fires-map.spec.js b/e2e/tests/fires-map.spec.js index 92ec5dd..2ec6e6c 100644 --- a/e2e/tests/fires-map.spec.js +++ b/e2e/tests/fires-map.spec.js @@ -22,10 +22,19 @@ import { waitForMap } from '../support/app.js'; // funcionaban— se quedan también sin teselas, porque entonces el control // aparece después de que el mapa ya esté montado. Probado en staging, // revertido: no basta con mover el momento del montaje. +// 5. Y el culpable NO es DefMapLayers: /zones (SubscriptionsMap) y /fires +// (FiresMap) usan el MISMO componente con las MISMAS props +// (``), y en staging /zones pinta 18 teselas con su +// capa en el pane mientras /fires se queda con CERO capas. En los dos casos +// el mapa está inicializado y con vista (el `.leaflet-proxy` está ahí y el +// pane tiene su transform): lo que falta en /fires es que se le añada la +// capa base. // -// El arreglo bueno pasa por que el conjunto de capas base esté decidido ANTES de -// montar el mapa (resolver la clave más arriba, o que GoogleMutantLayer sepa no -// hacer nada sin clave). +// O sea que hay que mirar cómo FiresMap monta y re-renderiza los hijos del +// MapContainer (tiene shouldComponentUpdate y un manejo de viewport con +// debounce), no DefMapLayers. La página de detalle de fuego es un caso aparte y +// además pide `satellite`, que es una capa de Google y por tanto ni siquiera +// existe hasta que la clave se resuelve. // // Quedan como test.fixme para que salgan como pendientes en cada ejecución en // vez de desaparecer en una nota. Quitar el .fixme al arreglarlo.