fix(maps): el mapa de /fires se quedaba en gris cuando hay clave de Google
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.
This commit is contained in:
parent
56f53e2c23
commit
82d0399c86
5 changed files with 343 additions and 7 deletions
1
e2e/.gitignore
vendored
1
e2e/.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
|
qa-shots/
|
||||||
|
|
|
||||||
259
e2e/staging-qa.mjs
Normal file
259
e2e/staging-qa.mjs
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
54
e2e/tests/fires-map.spec.js
Normal file
54
e2e/tests/fires-map.spec.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,23 +13,45 @@ const { BaseLayer } = LayersControl;
|
||||||
|
|
||||||
const defOpacity = 0.7;
|
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 {
|
class DefMapLayers extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
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() {
|
componentDidMount() {
|
||||||
const self = this;
|
this.mounted = true;
|
||||||
Gkeys.load((err, key) => {
|
const settle = (key) => {
|
||||||
self.setState({ gkey: 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() {
|
render() {
|
||||||
const { t } = this.props;
|
const { t } = this.props;
|
||||||
|
if (!this.state.ready) return null;
|
||||||
const osmgraylayer = (
|
const osmgraylayer = (
|
||||||
<BaseLayer checked={this.props.gray} name={t('Mapa gris de OpenStreetMap')}>
|
<BaseLayer checked={this.props.gray} name={t('Mapa gris de OpenStreetMap')}>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"gmaps": {
|
"gmaps": {
|
||||||
"key": "",
|
"key": "AIzaSyFAKE-KEY-FOR-LOCAL-REPRO-0000000",
|
||||||
"serverKey": ""
|
"serverKey": "AIzaSyFAKE-KEY-FOR-LOCAL-REPRO-0000000"
|
||||||
},
|
},
|
||||||
"piwik": {
|
"piwik": {
|
||||||
"url": "http://localhost:9/piwik.php",
|
"url": "http://localhost:9/piwik.php",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue