test(e2e): browser suite for the flows the server tests cannot see

Playwright under e2e/, its own npm project so Meteor never sees the dependency
(.meteorignore keeps the directory out of the bundle — a config file at the app
root is otherwise eagerly loaded into the server and crashes it).

Covers what the phase asked for: sign up / log in, creating a zone from the map
and watching the union get painted on /subscriptions and /zones, removing it
again, commenting on a fire, and switching language. The zone spec is the flow
that froze staging on 2026-07-30 — it passes only if the server is still
answering DDP while the union is recomputed.

docker-compose.e2e.yml is a throwaway stack (Mongo on tmpfs, no named volume) so
the destructive seeds cannot be pointed at anything real by accident;
playwright.config.js refuses to start against the staging or production domains.

i18n.spec.js leaves a test.fixme on a real bug found while writing it: the
language chosen in the profile does not survive a page reload.
This commit is contained in:
vjrj 2026-08-01 18:44:09 +02:00
parent 3b04ebceb3
commit a3f2929ff7
14 changed files with 708 additions and 0 deletions

125
.forgejo/workflows/e2e.yml Normal file
View file

@ -0,0 +1,125 @@
# Suite de navegador (Playwright) + smoke REST contra un stack EFÍMERO.
#
# POR QUÉ NOCTURNO Y NO EN CADA PUSH: esto construye el bundle de producción
# entero (`meteor build`, lo mismo que hace la imagen) y además baja un Chromium.
# aaron es un host compartido con `capacity: 1`: si esto corriera en cada push,
# la forja se quedaría sin turno. Los tests de servidor (build-image.yml) sí van
# en cada push y son los que bloquean la imagen.
#
# POR QUÉ NO USA docker compose: el runner no tiene socket de Docker (ver la nota
# de build-image.yml) — así que en vez de levantar docker-compose.e2e.yml, este
# job construye el bundle y lo arranca dentro de su propio contenedor, con Mongo
# como service container. El compose sigue siendo la forma de correrlo en local.
#
# ⚠️ Las dos siembras (smoke/seed.js y e2e/seed.js) BORRAN colecciones. Aquí eso
# es seguro porque el Mongo es un service container que muere con el job. Jamás
# apuntes esto a testfuegos.comunes.org.
name: e2e
on:
schedule:
# 04:15 UTC, con el runner libre.
- cron: '15 4 * * *'
workflow_dispatch:
jobs:
e2e:
runs-on: docker
container:
image: node:22-bookworm
# El pico es el `meteor build`, igual que en el job de la imagen.
options: --memory=4g --memory-swap=4g --cpus=3
services:
mongo:
image: mongo:7
# Nodo suelto, sin replica set: los service containers no dejan cambiar
# el comando de arranque. Sin oplog Meteor cae a polling (cada 10s), así
# que los tests que esperan a la unión tardan más — por eso sus timeouts
# son de 60s. En local (docker-compose.e2e.yml) sí hay replica set.
options: >-
--health-cmd "mongosh --quiet --eval 'db.adminCommand({ping:1}).ok'"
--health-interval 5s --health-timeout 5s --health-retries 20
env:
METEOR_ALLOW_SUPERUSER: '1'
NODE_OPTIONS: --dns-result-order=ipv4first --no-network-family-autoselection
METEOR_RELEASE: '3.1'
MONGO_URL: mongodb://mongo:27017/fuegos
ROOT_URL: http://localhost:3000
PORT: '3000'
BASE_URL: http://localhost:3000
E2E_BASE_URL: http://localhost:3000
SETTINGS: settings-ci.json
steps:
- name: Herramientas
run: |
set -e
apt-get update -qq
apt-get install -y --no-install-recommends \
curl ca-certificates git python3 python-is-python3 make g++ >/dev/null
# mongosh por npm: el cliente no viene en node:22 y el service container
# de mongo no es accesible con `docker exec` desde el job.
npm install -g mongosh@2 >/dev/null
- name: Descargar el código
env:
TOKEN: ${{ github.token }}
run: |
set -e
mkdir -p src
wget -q -O src.tar.gz \
"http://x-access-token:${TOKEN}@forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
tar xzf src.tar.gz -C src --strip-components=1
rm -f src.tar.gz
- name: Instalar meteor-tool
run: curl -fsSL "https://install.meteor.com/?release=${METEOR_RELEASE}" | sh
- name: Construir el bundle de producción
run: |
set -e
cd src
meteor npm ci --no-audit --no-fund
meteor build /tmp/build --directory --server-only
cd /tmp/build/bundle/programs/server && npm install --omit=dev --no-audit --no-fund
- name: Arrancar el servidor
run: |
set -e
cd /tmp/build/bundle
METEOR_SETTINGS="$(cat "${GITHUB_WORKSPACE}/src/settings-ci.json")" \
node main.js > /tmp/server.log 2>&1 &
for i in $(seq 1 90); do
wget -qO /dev/null "http://127.0.0.1:3000/" && break
sleep 2
done
wget -qO /dev/null "http://127.0.0.1:3000/" || { echo "El servidor no arrancó:"; tail -50 /tmp/server.log; exit 1; }
# El smoke va primero y con su propia siembra: compara respuestas byte a
# byte contra snapshots, así que cualquier dato que dejen los e2e (una
# suscripción, la unión recalculada) lo haría fallar.
- name: Smoke REST
run: |
set -e
cd src
mongosh --host mongo --quiet fuegos smoke/seed.js
node smoke/run.js
- name: Suite e2e
run: |
set -e
cd src
mongosh --host mongo --quiet fuegos e2e/seed.js
npm --prefix e2e ci --no-audit --no-fund
npx --prefix e2e playwright install --with-deps chromium
npx --prefix e2e playwright test
# Sin `actions/upload-artifact` (action JS, ver fase 9 §3): al menos el log
# del servidor y la lista de trazas quedan en la salida del job.
- name: Diagnóstico en caso de fallo
if: failure()
run: |
echo "===== últimas 200 líneas del servidor ====="
tail -200 /tmp/server.log || true
echo "===== trazas de Playwright ====="
ls -R src/e2e/test-results 2>/dev/null | head -50 || true

View file

@ -5,3 +5,5 @@ output.json
smoke
# Ops/deploy scripts (p.ej. scripts/*.mongo.js usan el global `db` de mongosh, no de Meteor)
scripts
# Suite Playwright: es un proyecto Node aparte (e2e/package.json), no codigo del servidor
e2e

78
docker-compose.e2e.yml Normal file
View file

@ -0,0 +1,78 @@
# Stack EFÍMERO para la suite e2e (Playwright) y el smoke REST.
#
# ⚠️ Todo lo de aquí es desechable y se siembra BORRANDO colecciones
# (e2e/seed.js, smoke/seed.js). Por eso el Mongo va en tmpfs y sin volumen
# nombrado: no puede sobrevivir al `down`, y así no hay forma de que alguien lo
# confunda con el stack local (docker-compose.yml) ni con staging. NUNCA apuntes
# la suite a testfuegos.comunes.org.
#
# Uso:
# docker compose -f docker-compose.e2e.yml up -d --build
# E2E_BASE_URL=http://localhost:3300 npm --prefix e2e test
# docker compose -f docker-compose.e2e.yml down -v
#
# settings-ci.json se monta tal cual: no lleva secretos (ver el fichero).
name: tcef-e2e
services:
mongo:
image: mongo:7
# replSet aunque sea de un nodo: sin oplog, Meteor cae a polling cada 10s y
# la unión de zonas tardaría eso en llegar al navegador — justo lo que los
# tests miden.
command: ["--replSet", "rs0", "--bind_ip_all"]
tmpfs:
- /data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
mongo-init:
image: mongo:7
depends_on:
mongo:
condition: service_healthy
restart: "no"
volumes:
- ./e2e/seed.js:/seed/e2e-seed.js:ro
entrypoint:
- bash
- -ec
- |
mongosh --host mongo --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongo:27017"}]}) }'
for i in $$(seq 1 30); do
state=$$(mongosh --host mongo --quiet --eval 'try { rs.isMaster().ismaster } catch (e) { false }')
[ "$$state" = "true" ] && break
sleep 1
done
# Las migraciones históricas de Meteor (up() aún síncronos) se marcan
# como aplicadas, igual que en el compose local.
mongosh --host mongo --quiet fuegos --eval 'db.migrations.replaceOne({_id: "control"}, {_id: "control", version: 18, locked: false}, {upsert: true})'
mongosh --host mongo --quiet fuegos /seed/e2e-seed.js
echo "mongo-init: replica set + migraciones + seed OK"
web:
build:
context: .
dockerfile: Dockerfile
image: tcef-web:e2e
depends_on:
mongo-init:
condition: service_completed_successfully
environment:
PORT: "3000"
ROOT_URL: http://localhost:3300
MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0
MONGO_OPLOG_URL: mongodb://mongo:27017/local?replicaSet=rs0
METEOR_SETTINGS_FILE: /run/settings-ci.json
# Usuarios de prueba (fixtures.js): el e2e crea los suyos, pero el smoke y
# la exploración manual esperan admin@admin.com / password.
TCEF_SEED: "1"
volumes:
- ./settings-ci.json:/run/settings-ci.json:ro
ports:
- "3300:3000"

3
e2e/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
playwright-report/
test-results/

60
e2e/README.md Normal file
View file

@ -0,0 +1,60 @@
# Suite e2e (Playwright)
Cubre en navegador los flujos que la suite de servidor no puede ver: registro y
acceso, alta y baja de una zona **desde el mapa**, que la unión de zonas se pinte
en `/subscriptions` y en `/zones`, comentario en el detalle de un fuego, y cambio
de idioma.
El motivo de que exista: el 2026-07-30 suscribirse a una zona congeló staging
(la unión bloqueaba el event loop y DDP dejaba de responder). Ningún test de
servidor habría visto eso — el navegador sí.
## ⚠️ Contra qué se puede correr
Solo contra un stack **desechable**: `docker-compose.e2e.yml` o un servidor de
desarrollo local. La siembra (`e2e/seed.js`) **borra colecciones**, y los tests
crean usuarios y zonas. `playwright.config.js` se niega a arrancar si
`E2E_BASE_URL` apunta a `testfuegos.comunes.org` o a los dominios de producción.
## Uso
Stack efímero completo (lo mismo que hace el CI):
```bash
docker compose -f docker-compose.e2e.yml up -d --build
npm --prefix e2e ci
npx --prefix e2e playwright install --with-deps chromium
E2E_BASE_URL=http://localhost:3300 npm --prefix e2e test
docker compose -f docker-compose.e2e.yml down -v
```
Contra un servidor de desarrollo (ciclo rápido mientras se escriben tests):
```bash
docker run -d --name tcef-e2e-mongo -p 27021:27017 --tmpfs /data/db mongo:7 --replSet rs0 --bind_ip_all
docker exec tcef-e2e-mongo mongosh --quiet --eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27017"}]})'
docker cp e2e/seed.js tcef-e2e-mongo:/tmp/seed.js && docker exec tcef-e2e-mongo mongosh --quiet fuegos /tmp/seed.js
export PATH="$HOME/.meteor:$PATH"
MONGO_URL='mongodb://localhost:27021/fuegos?replicaSet=rs0&directConnection=true' \
MONGO_OPLOG_URL='mongodb://localhost:27021/local?replicaSet=rs0&directConnection=true' \
ROOT_URL=http://localhost:3100 meteor --settings settings-ci.json --port 3100
npm --prefix e2e test # E2E_BASE_URL por defecto: http://localhost:3100
```
Ver el informe del último fallo: `npm --prefix e2e run report`.
## Dependencias externas
El navegador carga las teselas de CartoDB y el script de Google Maps (el
autocompletado de lugares). El runner necesita salida a internet; sin ella los
mapas no llegan a pintarse y los tests de zona fallan. La clave de Google puede
ir vacía: `Gkeys` ya no se queda esperándola.
## Lo que la suite documenta como roto
`i18n.spec.js` deja un `test.fixme` para un bug real: el idioma elegido en el
perfil **no sobrevive a una recarga** (se guarda en la cuenta y se aplica al
iniciar sesión, pero al recargar vuelve al idioma del navegador). Está ahí para
que aparezca en cada ejecución en vez de perderse en una nota.

76
e2e/package-lock.json generated Normal file
View file

@ -0,0 +1,76 @@
{
"name": "tcef-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tcef-e2e",
"devDependencies": {
"@playwright/test": "^1.49.1"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

15
e2e/package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "tcef-e2e",
"private": true,
"description": "Browser end-to-end suite for todos-contra-el-fuego-web. Kept out of the app's package.json on purpose: Meteor should never see these dependencies.",
"type": "module",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"report": "playwright show-report",
"install-browsers": "playwright install --with-deps chromium"
},
"devDependencies": {
"@playwright/test": "^1.49.1"
}
}

42
e2e/playwright.config.js Normal file
View file

@ -0,0 +1,42 @@
import { defineConfig, devices } from '@playwright/test';
// E2E_BASE_URL points at a THROWAWAY stack — the compose in docker-compose.e2e.yml
// or a local dev server. Never at testfuegos.comunes.org or anything else with
// data in it: these tests create users and subscriptions, and the seed step wipes
// collections.
const baseURL = process.env.E2E_BASE_URL || 'http://localhost:3100';
if (/testfuegos|fuegos\.comunes|fires\.comunes/.test(baseURL)) {
throw new Error(`Refusing to run the e2e suite against ${baseURL}: it seeds and writes data.`);
}
export default defineConfig({
testDir: './tests',
// Meteor's first page load compiles and ships a big bundle; the map then waits
// for tiles and DDP subscriptions.
timeout: 90 * 1000,
expect: { timeout: 20 * 1000 },
// Every spec creates its own user, but they share one Mongo and one union
// recompute queue; running them in parallel makes failures much harder to read
// for very little wall-clock on a runner that only has one job slot anyway.
workers: 1,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI
? [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]]
: [['list']],
use: {
baseURL,
// Both on first retry only: a trace per test would be gigabytes of artifacts
// for a suite that is green most of the time.
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 20 * 1000,
locale: 'es-ES'
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }
]
});

61
e2e/seed.js Normal file
View file

@ -0,0 +1,61 @@
/*
* Deterministic seed for the browser e2e suite (mongosh script).
*
* DESTRUCTIVE: it empties the collections it seeds. It is only ever meant for
* the throwaway Mongo of docker-compose.e2e.yml (or a local scratch one). Never
* point it at staging or production that is what e2e/seed.sh guards against.
*
* mongosh --host localhost:27021 fuegos e2e/seed.js
*
* The subscriptions collection is deliberately NOT seeded: the whole point of
* the zone spec is to create one through the UI and watch the union appear.
*/
var FIRE_A = ObjectId('e2e0000000000000000000a1');
var FIRE_B = ObjectId('e2e0000000000000000000b2');
// Madrid, so a zone created around the map's default view sees them.
var LAT = 40.41;
var LON = -3.70;
var WHEN = ISODate('2026-07-30T05:30:00.000Z');
var WHEN_B = ISODate('2026-07-30T06:30:00.000Z');
var FIXED = ISODate('2026-07-30T00:00:00.000Z');
db.activefires.deleteMany({});
db.activefires.insertMany([
{
_id: FIRE_A,
ourid: { type: 'Point', coordinates: [LON, LAT] },
lat: LAT, lon: LON, type: 'viirs', when: WHEN,
scan: 0.5, track: 0.4, acq_date: '2026-07-30', acq_time: '05:30',
satellite: 'N', confidence: 50, version: '1.0NRT', frp: 4.3,
daynight: 'N', bright_ti4: 330.2, bright_ti5: 291,
createdAt: FIXED, updatedAt: FIXED
},
{
_id: FIRE_B,
ourid: { type: 'Point', coordinates: [LON + 0.02, LAT + 0.02] },
lat: LAT + 0.02, lon: LON + 0.02, type: 'viirs', when: WHEN_B,
scan: 0.5, track: 0.4, acq_date: '2026-07-30', acq_time: '06:30',
satellite: 'N', confidence: 60, version: '1.0NRT', frp: 6.1,
daynight: 'N', bright_ti4: 331.0, bright_ti5: 292,
createdAt: FIXED, updatedAt: FIXED
}
]);
db.activefires.createIndex({ ourid: '2dsphere' });
db.activefiresunion.deleteMany({});
db.activefiresunion.createIndex({ shape: '2dsphere' });
db.comments.deleteMany({});
db.falsePositives.deleteMany({});
db.industries.deleteMany({});
// Anything left over from a previous run of the suite: users it created, their
// zones, and the stored union computed from them.
db.subscriptions.deleteMany({});
db.users.deleteMany({ 'emails.address': /^e2e\+/ });
db.siteSettings.deleteMany({ name: /^subs-(public|private)-union/ });
db.siteSettings.deleteMany({ name: 'subs-union-count' });
print('e2e seed OK: ' + db.activefires.countDocuments({}) + ' active fires, 0 subscriptions');

54
e2e/support/app.js Normal file
View file

@ -0,0 +1,54 @@
// 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.
// 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.
export const newEmail = () => `e2e+${Date.now()}${Math.floor(Math.random() * 1000)}@test.com`;
export const signUp = async (page, email = newEmail()) => {
await page.goto('/signup');
await page.fill('input[name=firstName]', 'E2E');
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');
// Signing up lands on the zones page.
await page.waitForURL('**/subscriptions', { timeout: 30000 });
return email;
};
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');
};
// The map only settles once leaflet has laid out its panes and the tiles for the
// current viewport are in.
export const waitForMap = async (page) => {
await page.locator('.leaflet-container').first().waitFor({ state: 'visible', timeout: 60000 });
await page.waitForTimeout(1500);
};
// Creates a zone through the real flow: the button on the zones map, the
// selection map, and the subscribe button. This is the flow that froze staging.
export const addZoneThroughTheMap = async (page) => {
await page.goto('/subscriptions');
await waitForMap(page);
await page.getByRole('button', { name: 'Añadir zona' }).click();
await page.waitForURL('**/subscriptions/new', { timeout: 30000 });
await waitForMap(page);
const map = page.locator('.leaflet-container').first();
const box = await map.boundingBox();
// Click slightly off-centre: the marker moves there, which also proves the
// map is interactive and not a dead tile layer.
await page.mouse.click(box.x + (box.width / 2) + 20, box.y + (box.height / 2) + 20);
await page.waitForTimeout(1000);
await page.getByRole('button', { name: /Suscribirme/ }).click();
await page.waitForURL('**/subscriptions', { timeout: 60000 });
};

44
e2e/tests/auth.spec.js Normal file
View file

@ -0,0 +1,44 @@
import { test, expect } from '@playwright/test';
import { signUp, logIn, newEmail } from '../support/app.js';
test.describe('registro y acceso', () => {
test('a new account can sign up, log out and log back in', async ({ page }) => {
const email = newEmail();
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 page.click('#logout');
await expect(page.locator('#login')).toBeVisible();
await logIn(page, email);
await expect(page.locator('#logout')).toBeVisible();
});
test('signing up is refused without accepting the terms', async ({ page }) => {
await page.goto('/signup');
await page.fill('input[name=firstName]', 'E2E');
await page.fill('input[name=lastName]', 'Tester');
await page.fill('input[name=emailAddress]', newEmail());
await page.fill('input[name=password]', 'password');
await expect(page.locator('#signUpSubmit')).toBeDisabled();
});
test('a wrong password does not sign anybody in', async ({ page }) => {
const email = newEmail();
await signUp(page, email);
await page.click('#logout');
await logIn(page, email, 'not-the-password');
await expect(page.locator('#loginSubmit')).toBeVisible();
await expect(page.locator('#logout')).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();
});
});

View file

@ -0,0 +1,40 @@
import { test, expect } from '@playwright/test';
import { signUp, waitForMap } from '../support/app.js';
// e2e/seed.js puts this one in activefires. Visiting an active fire archives it
// and canonicalizes the URL to /fire/archive/<other id>, which is itself worth
// pinning: it is the link every notification and every tweet points at.
const SEEDED_FIRE = '/fire/active/e2e0000000000000000000a1';
test.describe('detalle de un fuego y comentarios', () => {
test('a seeded fire opens, keeps its map and canonicalizes its URL', async ({ page }) => {
await page.goto(SEEDED_FIRE);
await expect(page.getByText(/Información adicional sobre fuego detectado/)).toBeVisible();
await expect(page).toHaveURL(/\/fire\/archive\/[0-9a-f]{24}$/);
await waitForMap(page);
await expect(page.getByText('Coordenadas: 40.41, -3.7')).toBeVisible();
});
test('a signed-in user can comment on a fire and sees it appear', async ({ page }) => {
await signUp(page);
await page.goto(SEEDED_FIRE);
await expect(page.getByText('Comentarios')).toBeVisible();
const comment = `Ardió el pinar de la ladera norte ${Date.now()}`;
await page.locator('textarea.create-comment').fill(comment);
await page.getByRole('button', { name: 'Añadir comentario' }).click();
await expect(page.getByText(comment)).toBeVisible({ timeout: 30000 });
// And it is really stored, not just echoed into the DOM.
await page.reload();
await expect(page.getByText(comment)).toBeVisible({ timeout: 30000 });
});
test('an anonymous visitor is asked to sign in instead of getting a comment box', async ({ page }) => {
await page.goto(SEEDED_FIRE);
await expect(page.getByRole('heading', { name: 'Comentarios' })).toBeVisible();
await expect(page.getByText('Necesitas iniciar sesión para añadir comentarios')).toBeVisible();
await expect(page.locator('textarea.create-comment')).toHaveCount(0);
});
});

54
e2e/tests/i18n.spec.js Normal file
View file

@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import { signUp } 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.waitForURL('**/profile', { timeout: 30000 });
};
const chooseLanguage = async (page, name) => {
await page.locator('.lang-selector').first().click();
await page.getByRole('button', { name }).click();
};
// The site is bilingual (es/en) and the language belongs to the account, not to
// the browser session: notifications and emails go out in it too.
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 openProfile(page);
await chooseLanguage(page, 'English');
await expect(page.locator('#moniZones')).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 chooseLanguage(page, 'Español');
await expect(page.locator('#moniZones')).toHaveText('Zonas vigiladas', { timeout: 30000 });
});
// KNOWN BUG, kept as a failing-by-design test instead of being quietly dropped:
// users.setLang does store the choice on the account, and Login.js applies it
// when you log in — but a plain reload of an already-logged-in session falls
// back to the browser's locale. Someone who picked English sees Spanish again
// on the next page load.
test.fixme('the chosen language survives a reload', async ({ page }) => {
await signUp(page);
await openProfile(page);
await chooseLanguage(page, 'English');
await expect(page.locator('#moniZones')).toHaveText('Monitored areas', { timeout: 30000 });
await page.reload();
await expect(page.locator('#moniZones')).toHaveText('Monitored areas', { timeout: 30000 });
});
});

View file

@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import { signUp, addZoneThroughTheMap, waitForMap } from '../support/app.js';
// This is the flow that froze staging on 2026-07-30: subscribing to a zone makes
// the server recompute the union of every subscription, and while that ran on the
// main thread DDP stopped answering and every browser hung. A green run here means
// the page came back, the union reached the client, and the map drew it.
test.describe('suscripción a una zona y unión de zonas', () => {
test('creating a zone lists it and paints the union on both maps', async ({ page }) => {
await signUp(page);
await expect(page.getByText('No estás suscrito a fuegos en ninguna zona')).toBeVisible();
await addZoneThroughTheMap(page);
// Back on the zones page, the "you have no zones" warning is gone.
await expect(page.getByText('No estás suscrito a fuegos en ninguna zona')).toHaveCount(0);
// The union is a GeoJSON layer: leaflet draws it as an SVG path in the
// overlay pane. Nothing is drawn there until the server has recomputed and
// published it, so this also pins that the recompute finishes at all.
await waitForMap(page);
await expect(page.locator('.leaflet-overlay-pane path').first()).toBeVisible({ timeout: 60000 });
await page.goto('/zones');
await waitForMap(page);
await expect(page.locator('.leaflet-overlay-pane path').first()).toBeVisible({ timeout: 60000 });
});
test('the site keeps answering while the union is recomputed', async ({ page }) => {
await signUp(page);
await addZoneThroughTheMap(page);
// A DDP round trip right after the insert: if the union blocked the event
// loop again, this navigation would hang instead of rendering.
await page.goto('/fires');
await waitForMap(page);
await expect(page.locator('.leaflet-container').first()).toBeVisible();
});
// Removing is the other half of the same server-side path: it schedules a full
// recompute instead of an incremental merge.
test('a zone can be removed again from the map', async ({ page }) => {
await signUp(page);
await addZoneThroughTheMap(page);
await expect(page.getByText('En verde, áreas de las que recibirás alertas de fuegos')).toBeVisible();
await page.getByRole('button', { name: /Editar/i }).click();
await page.locator('.leaflet-marker-icon').first().click();
await expect(page.getByText('¿Estás seguro/a?')).toBeVisible();
await page.getByRole('button', { name: 'Sí', exact: true }).click();
await expect(page.getByText('No estás suscrito a fuegos en ninguna zona')).toBeVisible({ timeout: 60000 });
});
});