From a3f2929ff73e177c54f66fb9ee482981ab4b4955 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 1 Aug 2026 18:44:09 +0200 Subject: [PATCH] test(e2e): browser suite for the flows the server tests cannot see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .forgejo/workflows/e2e.yml | 125 +++++++++++++++++++++++++++++++++ .meteorignore | 2 + docker-compose.e2e.yml | 78 ++++++++++++++++++++ e2e/.gitignore | 3 + e2e/README.md | 60 ++++++++++++++++ e2e/package-lock.json | 76 ++++++++++++++++++++ e2e/package.json | 15 ++++ e2e/playwright.config.js | 42 +++++++++++ e2e/seed.js | 61 ++++++++++++++++ e2e/support/app.js | 54 ++++++++++++++ e2e/tests/auth.spec.js | 44 ++++++++++++ e2e/tests/fire-comment.spec.js | 40 +++++++++++ e2e/tests/i18n.spec.js | 54 ++++++++++++++ e2e/tests/zone-union.spec.js | 54 ++++++++++++++ 14 files changed, 708 insertions(+) create mode 100644 .forgejo/workflows/e2e.yml create mode 100644 docker-compose.e2e.yml create mode 100644 e2e/.gitignore create mode 100644 e2e/README.md create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.js create mode 100644 e2e/seed.js create mode 100644 e2e/support/app.js create mode 100644 e2e/tests/auth.spec.js create mode 100644 e2e/tests/fire-comment.spec.js create mode 100644 e2e/tests/i18n.spec.js create mode 100644 e2e/tests/zone-union.spec.js diff --git a/.forgejo/workflows/e2e.yml b/.forgejo/workflows/e2e.yml new file mode 100644 index 0000000..b57fba9 --- /dev/null +++ b/.forgejo/workflows/e2e.yml @@ -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 diff --git a/.meteorignore b/.meteorignore index eeec0b9..eda8931 100644 --- a/.meteorignore +++ b/.meteorignore @@ -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 diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..74a986a --- /dev/null +++ b/docker-compose.e2e.yml @@ -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" diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..945fcd0 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +playwright-report/ +test-results/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..78f6606 --- /dev/null +++ b/e2e/README.md @@ -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. diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..25508f8 --- /dev/null +++ b/e2e/package-lock.json @@ -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" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..bb7aaac --- /dev/null +++ b/e2e/package.json @@ -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" + } +} diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 0000000..d7e8e99 --- /dev/null +++ b/e2e/playwright.config.js @@ -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'] } } + ] +}); diff --git a/e2e/seed.js b/e2e/seed.js new file mode 100644 index 0000000..5d682b9 --- /dev/null +++ b/e2e/seed.js @@ -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'); diff --git a/e2e/support/app.js b/e2e/support/app.js new file mode 100644 index 0000000..0237de4 --- /dev/null +++ b/e2e/support/app.js @@ -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 }); +}; diff --git a/e2e/tests/auth.spec.js b/e2e/tests/auth.spec.js new file mode 100644 index 0000000..88d3b69 --- /dev/null +++ b/e2e/tests/auth.spec.js @@ -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(); + }); +}); diff --git a/e2e/tests/fire-comment.spec.js b/e2e/tests/fire-comment.spec.js new file mode 100644 index 0000000..98bd9b8 --- /dev/null +++ b/e2e/tests/fire-comment.spec.js @@ -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/, 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); + }); +}); diff --git a/e2e/tests/i18n.spec.js b/e2e/tests/i18n.spec.js new file mode 100644 index 0000000..8ae38b2 --- /dev/null +++ b/e2e/tests/i18n.spec.js @@ -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 }); + }); +}); diff --git a/e2e/tests/zone-union.spec.js b/e2e/tests/zone-union.spec.js new file mode 100644 index 0000000..4eb5cbf --- /dev/null +++ b/e2e/tests/zone-union.spec.js @@ -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 }); + }); +});