diff --git a/e2e/support/app.js b/e2e/support/app.js index 0237de4..546fcc8 100644 --- a/e2e/support/app.js +++ b/e2e/support/app.js @@ -1,6 +1,23 @@ // Shared helpers. Everything here drives the UI the way a person would: the // point of this suite is the wiring between browser, DDP and Mongo, so reaching // into Meteor.call() from the page would defeat it. +// +// ⚠️ NADA de selectores por id. `imports/ui/components/Utils/TestUtils.js` +// devuelve el id SOLO en desarrollo, así que contra un bundle de producción +// —que es justo lo que levanta el job nocturno de CI— esos elementos no lo +// llevan. Aquí se va por href, rol y nombre de campo, que existen en los dos. + +export 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"]' +}; + +export const botonRegistro = page => page.getByRole('button', { name: 'Registrarse', exact: true }); +export const botonEntrar = page => page.getByRole('button', { name: 'Iniciar sesión', exact: true }); // A fresh address per test: the seed removes every e2e+* user, and reusing one // across specs makes failures depend on the order they ran in. @@ -12,8 +29,8 @@ export const signUp = async (page, email = newEmail()) => { await page.fill('input[name=lastName]', 'Tester'); await page.fill('input[name=emailAddress]', email); await page.fill('input[name=password]', 'password'); - await page.check('#tos'); - await page.click('#signUpSubmit'); + await page.check('input[name=tos]'); + await botonRegistro(page).click(); // Signing up lands on the zones page. await page.waitForURL('**/subscriptions', { timeout: 30000 }); return email; @@ -23,7 +40,7 @@ export const logIn = async (page, email, password = 'password') => { await page.goto('/login'); await page.fill('input[name=emailAddress]', email); await page.fill('input[name=password]', password); - await page.click('#loginSubmit'); + await botonEntrar(page).click(); }; // The map only settles once leaflet has laid out its panes and the tiles for the diff --git a/e2e/tests/auth.spec.js b/e2e/tests/auth.spec.js index 88d3b69..cf11006 100644 --- a/e2e/tests/auth.spec.js +++ b/e2e/tests/auth.spec.js @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { signUp, logIn, newEmail } from '../support/app.js'; +import { signUp, logIn, newEmail, NAV, botonRegistro, botonEntrar } from '../support/app.js'; test.describe('registro y acceso', () => { test('a new account can sign up, log out and log back in', async ({ page }) => { @@ -7,14 +7,14 @@ test.describe('registro y acceso', () => { await signUp(page, email); // Signed in: the navbar swaps the login links for the profile and logout ones. - await expect(page.locator('#logout')).toBeVisible(); - await expect(page.locator('#profile')).toContainText('E2E Tester'); + await expect(page.locator(NAV.salir)).toBeVisible(); + await expect(page.locator(NAV.perfil)).toContainText('E2E Tester'); - await page.click('#logout'); - await expect(page.locator('#login')).toBeVisible(); + await page.locator(NAV.salir).click(); + await expect(page.locator(NAV.entrar)).toBeVisible(); await logIn(page, email); - await expect(page.locator('#logout')).toBeVisible(); + await expect(page.locator(NAV.salir)).toBeVisible(); }); test('signing up is refused without accepting the terms', async ({ page }) => { @@ -24,21 +24,21 @@ test.describe('registro y acceso', () => { await page.fill('input[name=emailAddress]', newEmail()); await page.fill('input[name=password]', 'password'); - await expect(page.locator('#signUpSubmit')).toBeDisabled(); + await expect(botonRegistro(page)).toBeDisabled(); }); test('a wrong password does not sign anybody in', async ({ page }) => { const email = newEmail(); await signUp(page, email); - await page.click('#logout'); + await page.locator(NAV.salir).click(); await logIn(page, email, 'not-the-password'); - await expect(page.locator('#loginSubmit')).toBeVisible(); - await expect(page.locator('#logout')).toHaveCount(0); + await expect(botonEntrar(page)).toBeVisible(); + await expect(page.locator(NAV.salir)).toHaveCount(0); }); test('the private zones page sends anonymous visitors to the login', async ({ page }) => { await page.goto('/subscriptions'); - await expect(page.locator('#loginSubmit')).toBeVisible(); + await expect(botonEntrar(page)).toBeVisible(); }); }); diff --git a/e2e/tests/fires-map.spec.js b/e2e/tests/fires-map.spec.js index 2ec6e6c..675c293 100644 --- a/e2e/tests/fires-map.spec.js +++ b/e2e/tests/fires-map.spec.js @@ -1,45 +1,30 @@ import { test, expect } from '@playwright/test'; import { waitForMap } from '../support/app.js'; -// ⛔ BUG ABIERTO: el mapa de /fires se queda EN GRIS cuando hay clave de Google -// Maps configurada — ni teselas, ni fuegos, ni suscripción por viewport, con el -// "Actualizando…" eterno. Es lo que se ve en staging. +// El "mapa gris" de /fires: en staging la página no pintaba ninguna tesela, no +// creaba la suscripción por viewport y se quedaba en "Actualizando…" para +// siempre. Causa, encontrada reproduciéndolo con el bundle de producción en +// local (con el servidor de desarrollo NO se ve): // -// Lo que SÍ está establecido (2026-08-01): +// Tracker.autorun(() => { +// if ((centerStored !== [0, 0] || geolocation.get()) && geoInit) { +// center.set(centerStored || geolocation.get()); +// geoInit = false; +// } // -// 1. NO es el navegador sin pantalla. El mismo Playwright headless pinta la -// portada y /zones de staging, y pinta /fires perfectamente contra un -// servidor local sin clave. La nota del plan que lo achacaba a eso es falsa. -// 2. Depende de la clave de Google. Basta con poner una clave (aunque sea -// falsa) en settings-ci.json para reproducirlo en local: 15 teselas sin -// clave, 0 con ella. Por eso settings-ci.json lleva una: para que esto se -// vea aquí y no solo en staging. -// 3. El control de capas se monta con dos capas base y, al resolverse la clave, -// se le añaden tres de Google. En esa segunda pasada react-leaflet se deja -// por el camino la capa base marcada. -// 4. Montar el control UNA sola vez, esperando a la clave, arregla el caso -// local pero ROMPE staging todavía más: allí la portada y /zones —que -// 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. +// `centerStored !== [0, 0]` compara contra un array recién creado, así que es +// SIEMPRE cierto. En un navegador nuevo —sin centro en localStorage y con la +// geolocalización aún sin resolver— eso hacía `center.set(undefined)` y dejaba +// `geoInit` en false, o sea para siempre. Un `` sin `center` no +// recibe `setView`, y un mapa de Leaflet sin vista no carga capa base ni +// teselas ni dispara `whenReady`, con lo que tampoco se crea la suscripción por +// viewport. En desarrollo no se veía porque la geolocalización ya estaba +// resuelta cuando el mapa se montaba. // -// 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. +// ⚠️ Estos tests solo muerden contra un bundle de PRODUCCIÓN, que es lo que +// levanta el job nocturno de CI. Contra el servidor de desarrollo pasan igual. test.describe('mapa de fuegos activos', () => { - test.fixme('paints its tiles', async ({ page }) => { + test('paints its tiles', async ({ page }) => { await page.goto('/fires'); await waitForMap(page); @@ -47,20 +32,17 @@ test.describe('mapa de fuegos activos', () => { expect(await page.locator('.leaflet-tile').count()).toBeGreaterThan(0); }); - test.fixme('offers every base layer and keeps the checked one', async ({ page }) => { + test('keeps exactly one base layer, and it is on the map', 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); + expect(await page.locator('.leaflet-control-layers-base input').count()).toBeGreaterThanOrEqual(2); + expect(await page.locator('.leaflet-control-layers-base input:checked').count()).toEqual(1); + // Marcada en el control pero ausente del mapa es justo el síntoma del bug. + expect(await page.locator('.leaflet-tile-pane .leaflet-layer').count()).toBeGreaterThan(0); }); - test.fixme('subscribes to the fires of the current viewport', async ({ page }) => { + test('subscribes to the fires of the current viewport', async ({ page }) => { await page.goto('/fires'); await waitForMap(page); @@ -69,7 +51,7 @@ test.describe('mapa de fuegos activos', () => { expect(names).toContain('activefiresunionmyloc'); }); - test.fixme('does not get stuck on "Actualizando…"', async ({ page }) => { + test('does not get stuck on "Actualizando…"', async ({ page }) => { await page.goto('/fires'); await waitForMap(page); await page.waitForTimeout(5000); diff --git a/e2e/tests/i18n.spec.js b/e2e/tests/i18n.spec.js index 8ae38b2..5e556dd 100644 --- a/e2e/tests/i18n.spec.js +++ b/e2e/tests/i18n.spec.js @@ -1,11 +1,11 @@ import { test, expect } from '@playwright/test'; -import { signUp } from '../support/app.js'; +import { signUp, NAV } from '../support/app.js'; // Through the navbar, not page.goto('/profile'): a cold load of that URL bounces // to /subscriptions (the Authenticated wrapper decides before Meteor has resumed // the login token). Worth its own fix, but it is not what this spec is about. const openProfile = async (page) => { - await page.click('#profile'); + await page.locator(NAV.perfil).click(); await page.waitForURL('**/profile', { timeout: 30000 }); }; @@ -19,22 +19,22 @@ const chooseLanguage = async (page, name) => { test.describe('cambio de idioma', () => { test('the interface follows the language chosen in the profile', async ({ page }) => { await signUp(page); - await expect(page.locator('#moniZones')).toHaveText('Zonas vigiladas'); + await expect(page.locator(NAV.zonas)).toHaveText('Zonas vigiladas'); await openProfile(page); await chooseLanguage(page, 'English'); - await expect(page.locator('#moniZones')).toHaveText('Monitored areas', { timeout: 30000 }); + await expect(page.locator(NAV.zonas)).toHaveText('Monitored areas', { timeout: 30000 }); }); test('going back to Spanish works too', async ({ page }) => { await signUp(page); await openProfile(page); await chooseLanguage(page, 'English'); - await expect(page.locator('#moniZones')).toHaveText('Monitored areas', { timeout: 30000 }); + await expect(page.locator(NAV.zonas)).toHaveText('Monitored areas', { timeout: 30000 }); await chooseLanguage(page, 'Español'); - await expect(page.locator('#moniZones')).toHaveText('Zonas vigiladas', { timeout: 30000 }); + await expect(page.locator(NAV.zonas)).toHaveText('Zonas vigiladas', { timeout: 30000 }); }); // KNOWN BUG, kept as a failing-by-design test instead of being quietly dropped: @@ -46,9 +46,9 @@ test.describe('cambio de idioma', () => { await signUp(page); await openProfile(page); await chooseLanguage(page, 'English'); - await expect(page.locator('#moniZones')).toHaveText('Monitored areas', { timeout: 30000 }); + await expect(page.locator(NAV.zonas)).toHaveText('Monitored areas', { timeout: 30000 }); await page.reload(); - await expect(page.locator('#moniZones')).toHaveText('Monitored areas', { timeout: 30000 }); + await expect(page.locator(NAV.zonas)).toHaveText('Monitored areas', { timeout: 30000 }); }); }); diff --git a/imports/ui/components/Maps/MapBridge.js b/imports/ui/components/Maps/MapBridge.js index bcfe22e..b2aa927 100644 --- a/imports/ui/components/Maps/MapBridge.js +++ b/imports/ui/components/Maps/MapBridge.js @@ -16,19 +16,54 @@ export const MapReady = ({ onReady }) => { // sin bounds: en /fires eso dejaba la suscripcion por viewport sin crear y el // mapa vacio (0 capas) pese a haber 7470 fuegos. Esperamos a que el mapa este // listo Y tenga tamaño real; entregamos una sola vez. + let observer = null; + let poll = null; + + const stopWatching = () => { + if (observer) { observer.disconnect(); observer = null; } + if (poll) { clearInterval(poll); poll = null; } + }; + const deliver = () => { if (cancelled || delivered) return; map.invalidateSize(); const size = map.getSize(); if (!size || size.x === 0 || size.y === 0) return; delivered = true; + stopWatching(); onReady(map); }; + map.whenReady(deliver); map.on('resize', deliver); + + // Y aquí está lo que faltaba. El contenedor puede coger su tamaño DESPUÉS + // del montaje —en producción la hoja de estilos se aplica más tarde que el + // primer render de React, cosa que en desarrollo no pasa porque el CSS lo + // inyecta el propio JS— y Leaflet no vigila su contenedor: solo emite + // `resize` cuando ALGUIEN llama a invalidateSize(). Como nadie lo hacía, el + // mapa se quedaba con tamaño 0 para siempre: sin capa base, sin teselas y + // sin la suscripción por viewport que crea el padre. Es el "mapa gris" de + // /fires en staging, que en local no se veía justamente porque en + // desarrollo el CSS llega a tiempo. + const container = map.getContainer(); + if (container && typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(deliver); + observer.observe(container); + } + // Red de seguridad para navegadores sin ResizeObserver y para cambios de + // tamaño que no lo disparen; se apaga sola al entregar o a los 10 s. + let attempts = 0; + poll = setInterval(() => { + attempts += 1; + deliver(); + if (delivered || attempts > 20) stopWatching(); + }, 500); + return () => { cancelled = true; map.off('resize', deliver); + stopWatching(); }; }, [map]); // eslint-disable-line react-hooks/exhaustive-deps return null; diff --git a/imports/ui/pages/FiresMap/FiresMap.js b/imports/ui/pages/FiresMap/FiresMap.js index 99d57e7..45adc62 100644 --- a/imports/ui/pages/FiresMap/FiresMap.js +++ b/imports/ui/pages/FiresMap/FiresMap.js @@ -8,7 +8,6 @@ import { ButtonGroup, Row, Col, Form } from 'react-bootstrap'; import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { withTracker } from 'meteor/react-meteor-data'; -import { Tracker } from 'meteor/tracker'; import { Helmet } from 'react-helmet-async'; import { Trans, withTranslation } from 'react-i18next'; import { MapContainer } from 'react-leaflet'; @@ -441,47 +440,59 @@ export default withTranslation()(withTracker(() => { } // Disable, because this increase the number of fires by one // Meteor.subscribe('lastFireDetected'); - Tracker.autorun(() => { - if ((centerStored !== [0, 0] || geolocation.get()) && geoInit) { - center.set(centerStored || geolocation.get()); - // console.log(`Geolocation ${geolocation.get()}`); - geoInit = false; - } - if (mapSize.get() && mapSize.get()[0].lng && mapSize.get()[1].lat) { - subscription = Meteor.subscribe( - 'activefiresmyloc', - mapSize.get()[0].lng, - mapSize.get()[0].lat, - mapSize.get()[1].lng, - mapSize.get()[1].lat, - marks.get() && zoom.get() >= MAXZOOM - ); - subscriptionUnion = Meteor.subscribe( - 'activefiresunionmyloc', - mapSize.get()[0].lng, - mapSize.get()[0].lat, - mapSize.get()[1].lng, - mapSize.get()[1].lat, - false - ); - alertSubscription = Meteor.subscribe( - 'fireAlerts', - mapSize.get()[0].lng, - mapSize.get()[0].lat, - mapSize.get()[1].lng, - mapSize.get()[1].lat - ); - /* if (withIndustries) { - Meteor.subscribe( - 'industriesMyloc', - mapSize.get()[0].lng, - mapSize.get()[0].lat, - mapSize.get()[1].lng, - mapSize.get()[1].lat - ); - } */ - } - }); + // ⚠️ Antes esto vivía dentro de un `Tracker.autorun` ANIDADO en el propio + // withTracker, y eso tenía dos problemas. Uno: la computación de fuera no + // dependía de `mapSize`, así que cuando el mapa por fin publicaba su viewport + // y se creaban las suscripciones, `loading` se había calculado ya con + // `subscription` a undefined — y no volvía a recalcularse, con lo que la + // página se quedaba con el "Actualizando…" puesto para siempre. Dos: cada + // pasada del withTracker creaba OTRO autorun que nadie paraba. + // + // Aquí ya estamos dentro de una computación reactiva: leyendo `mapSize.get()` + // la dependencia queda registrada, y las suscripciones creadas dentro se + // paran solas cuando la computación se invalida, que es justo lo que se + // quiere al cambiar el viewport. + // + // La condición del centro era `(centerStored !== [0, 0] || geolocation.get()) && geoInit`, + // y `centerStored !== [0, 0]` compara contra un array recién creado: SIEMPRE + // cierto. En un navegador nuevo —sin centro guardado y sin geolocalización + // todavía— hacía `center.set(undefined)` y dejaba `geoInit` en false, o sea + // para siempre. Un `` sin `center` no recibe `setView`, y un + // mapa de Leaflet sin vista no carga capa base ni teselas ni dispara + // `whenReady`. Ese era el "mapa gris" de /fires, que solo se veía en + // producción porque en desarrollo la geolocalización llegaba a tiempo. + const nextCenter = centerStored || geolocation.get(); + if (geoInit && nextCenter) { + center.set(nextCenter); + geoInit = false; + } + + const bounds = mapSize.get(); + if (bounds && bounds[0].lng && bounds[1].lat) { + subscription = Meteor.subscribe( + 'activefiresmyloc', + bounds[0].lng, + bounds[0].lat, + bounds[1].lng, + bounds[1].lat, + marks.get() && zoom.get() >= MAXZOOM + ); + subscriptionUnion = Meteor.subscribe( + 'activefiresunionmyloc', + bounds[0].lng, + bounds[0].lat, + bounds[1].lng, + bounds[1].lat, + false + ); + alertSubscription = Meteor.subscribe( + 'fireAlerts', + bounds[0].lng, + bounds[0].lat, + bounds[1].lng, + bounds[1].lat + ); + } Meteor.subscribe('activefirestotal'); Meteor.subscribe('activefiresuniontotal');