pediatric-ai-scribe-v3/scripts/e2e.sh
Daniel 993442f98a feat: the API describes itself, at /api/openapi.json
docs/api-reference.md was hand-written, and by the time anyone checked
it was documenting twenty-three endpoints that answer 404 while missing
others that exist. That is what hand-written reference material does: it
is correct on the day it is written and silently wrong afterwards. A
second hand-written document, in YAML this time, would rot the same way.

So paths, methods and mount points are read from the Express router
stack at request time. They cannot disagree with the app, because they
are the app: 186 paths, 215 operations, and — checked — no /learning
endpoints, which is what the prose version went on claiming for weeks
after that feature was deleted.

What introspection cannot know is what an endpoint is *for*. That half
lives in src/utils/openapiRoutes.js, keyed by "METHOD /path", and it is
the half that rots, so it is the half that is enforced: a Playwright
spec fetches the live document and fails when the number of operations
without a summary rises above 199 — the debt as measured today. A
ratchet, not a target. Adding an endpoint pushes the count over and
fails the build; describing one lowers the number. The failure lists the
operations by name, so it says what to write.

Whether an operation is public is stated per route rather than inferred
from middleware. Guessing wrong there is worse in both directions:
calling a public endpoint protected hides a hole, and the reverse
invites a bug report.

The contract spec lives in e2e rather than the unit suite because it
needs the whole app mounted, and requiring server.js from node:test
pulls in the database pool and hangs the run — that has happened here
before.

Also: e2e now runs in CI on dev, gated by a shell check inside the step
rather than a job-level "if", which this Forgejo dispatches anyway and
then kills with "Early termination".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 00:52:55 +02:00

101 lines
4.8 KiB
Bash
Executable file

#!/usr/bin/env bash
# End-to-end tests: a real browser, against a real copy of the whole app, on a
# database that did not exist a minute ago.
#
# scripts/e2e.sh # fresh stack, run every spec, leave it up
# scripts/e2e.sh auth-screen # only specs matching a pattern
# scripts/e2e.sh --down # tear the stack down and stop
# scripts/e2e.sh --no-reset # reuse the running stack and its data
#
# Every run recreates the database from empty, so nothing carries over between
# runs and the migrations are proved from nothing each time. The stack is left
# running afterwards on purpose: http://127.0.0.1:3553 is then a working copy
# of the app you can click around in, and http://127.0.0.1:3554 is the report.
set -euo pipefail
cd "$(dirname "$0")/.."
COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.e2e.yml)
SERVICES=(postgres-e2e redis-e2e pediatric-scribe-e2e)
PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v1.50.0-noble"
APP_URL="http://127.0.0.1:3553"
REPORT_URL="http://127.0.0.1:3554"
RESET=true
GREP=""
for arg in "$@"; do
case "$arg" in
--down)
echo "==> Tearing down the e2e stack"
"${COMPOSE[@]}" rm -sfv "${SERVICES[@]}" e2e-report >/dev/null 2>&1 || true
echo " gone (its database was in RAM, so nothing is left on disk)"
exit 0 ;;
--no-reset) RESET=false ;;
-*) echo "unknown option: $arg" >&2; exit 2 ;;
*) GREP="$arg" ;;
esac
done
# ── Static reference lint ─────────────────────────────────────────────
# Catches the class of bug where a JS file reaches for an id that no HTML
# element (or dynamic id assignment anywhere in the repo) ever produces — the
# lightbox and adminMilestones dead-code bugs were both this shape and both
# went undetected until someone tripped over them in the real app. Cheap, so
# it runs first and fails before anything is built.
echo "==> Static reference lint"
docker run --rm -v "$PWD:/work" -w /work node:20-alpine node scripts/lint-references.js
# ── A stack with nothing in it ────────────────────────────────────────
# rm -sfv, not `down`: `down` on a merged compose file would take production's
# services with it. This names only the e2e ones. Their database and Redis are
# tmpfs, so removing the containers is what makes the data ephemeral.
if [ "$RESET" = true ]; then
echo "==> Recreating the e2e stack (fresh database)"
"${COMPOSE[@]}" rm -sfv "${SERVICES[@]}" >/dev/null 2>&1 || true
GIT_REVISION="$(git rev-parse HEAD 2>/dev/null || echo unknown)" \
"${COMPOSE[@]}" up -d --build --wait "${SERVICES[@]}"
else
echo "==> Reusing the running e2e stack"
GIT_REVISION="$(git rev-parse HEAD 2>/dev/null || echo unknown)" \
"${COMPOSE[@]}" up -d --wait "${SERVICES[@]}"
fi
# What is actually being tested. A stale image here would make a green run
# meaningless, which is the failure mode worth naming out loud.
RUNNING="$(curl -fsS --max-time 10 "$APP_URL/api/build" | sed -n 's/.*"buildId":"\([^"]*\)".*/\1/p' || true)"
echo " testing revision ${RUNNING:-<unknown>}"
# ── Seed ──────────────────────────────────────────────────────────────
# The accounts the fixtures log in as. The database is empty every run, so
# unlike before this is not optional and a failure here is fatal: tests that
# cannot log in fail in a way that looks like the app is broken.
echo "==> Seeding e2e accounts"
"${COMPOSE[@]}" exec -T pediatric-scribe-e2e node e2e/seed.js
# ── The browser ───────────────────────────────────────────────────────
# Host network and a loopback URL, because the browser only treats loopback as
# a secure context over plain http, and the app cannot sign in without one.
echo "==> Playwright"
set +e
docker run --rm --ipc=host \
--network=host \
-v "$PWD/e2e":/work \
-w /work \
-e BASE_URL="$APP_URL" \
-e CI=true \
-e OPENAPI_UNDESCRIBED_BUDGET="${OPENAPI_UNDESCRIBED_BUDGET:-}" \
"$PLAYWRIGHT_IMAGE" \
sh -c "npm install --no-audit --no-fund --silent && npx playwright test ${GREP:+--grep \"$GREP\"}"
STATUS=$?
set -e
# The report is worth serving whether the run passed or failed — a pass is
# where you check that a spec did what you thought it did.
"${COMPOSE[@]}" up -d e2e-report >/dev/null 2>&1 || true
echo
if [ "$STATUS" -eq 0 ]; then echo "==> ✅ e2e passed"; else echo "==> ❌ e2e failed (exit $STATUS)"; fi
echo " app $APP_URL (a working copy, throwaway data)"
echo " report $REPORT_URL (traces and screenshots of any failure)"
echo " stop scripts/e2e.sh --down"
exit "$STATUS"