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');