test(e2e): afinar el diagnóstico del mapa gris — no es DefMapLayers
All checks were successful
build-image / test (push) Successful in 2m10s
build-image / build (push) Successful in 10m57s

/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.
This commit is contained in:
vjrj 2026-08-02 14:50:39 +02:00
parent 1153556f47
commit b0ca838788
2 changed files with 82 additions and 37 deletions

View file

@ -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=<id hex de un activefire> 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';
});
}

View file

@ -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
// (`<DefMapLayers gray />`), 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.