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:
parent
3b04ebceb3
commit
a3f2929ff7
14 changed files with 708 additions and 0 deletions
125
.forgejo/workflows/e2e.yml
Normal file
125
.forgejo/workflows/e2e.yml
Normal 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
|
||||
|
|
@ -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
78
docker-compose.e2e.yml
Normal 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
3
e2e/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
60
e2e/README.md
Normal file
60
e2e/README.md
Normal 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
76
e2e/package-lock.json
generated
Normal 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
15
e2e/package.json
Normal 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
42
e2e/playwright.config.js
Normal 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
61
e2e/seed.js
Normal 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
54
e2e/support/app.js
Normal 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
44
e2e/tests/auth.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
40
e2e/tests/fire-comment.spec.js
Normal file
40
e2e/tests/fire-comment.spec.js
Normal 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
54
e2e/tests/i18n.spec.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
54
e2e/tests/zone-union.spec.js
Normal file
54
e2e/tests/zone-union.spec.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue