#!/usr/bin/env bash # scripts/e2e-ui.sh — boot a FRESH ErsatzTV instance and run the UI-interactive Playwright flows # (ersatztv#445). The curl counterpart is scripts/e2e-functional.sh; see docs/e2e-local.md for both. # # Usage: # scripts/e2e-ui.sh [CONFIG_DIR] # extra args after CONFIG_DIR are passed to `playwright test` # # CONFIG_DIR defaults to a fresh `mktemp -d`. A FRESH dir is REQUIRED, not merely preferred: the first # spec asserts the Setup (first-run admin claim) gate, which only appears while the server has no # local admin. Point this at a reused config dir and that spec fails by design. # # Unlike e2e-local.sh, this script OWNS the instance lifecycle: it boots the server, runs the specs, # and always kills the server on the way out (success, failure, or interrupt). Exit status is # Playwright's, so this is safe to use directly as a CI gate. # # Assumes `dotnet build` + `cd web && npm ci && npm run build` have already run — same contract as # e2e-local.sh, which this delegates the boot to. set -euo pipefail REPO_ROOT="$(git rev-parse --show-toplevel)" PORT="${ETV_UI_PORT:-8409}" CONFIG_DIR="${1:-$(mktemp -d)}" shift || true # The Chromium build Playwright drives is baked into the CI toolchain image at this path # (docker/ci/Dockerfile); locally it lands in the default per-user cache. Only export the shared # path when it actually exists, so a local run keeps using the user cache. if [ -z "${PLAYWRIGHT_BROWSERS_PATH:-}" ] && [ -d /ms-playwright ]; then export PLAYWRIGHT_BROWSERS_PATH=/ms-playwright fi if [ ! -d "$REPO_ROOT/web/node_modules/@playwright/test" ]; then echo "error: web/node_modules/@playwright/test is missing — run 'cd web && npm ci' first" >&2 exit 1 fi # Version-drift guard: actually LAUNCH the browser and close it. Playwright pins a browser REVISION # per package version, so bumping web/package.json's @playwright/test pin (e.g. by Renovate) without # rebuilding the CI toolchain image leaves no usable browser. Failing here with an actionable message # beats Playwright's bare "Executable doesn't exist at ..." mid-suite. # # Probing by LAUNCH rather than by path is deliberate: the CI image bakes only # `chromium-headless-shell` (267M vs 656M for full chromium), and `chromium.executablePath()` reports # the FULL chromium path, which does not exist there — a path check would fail on a perfectly good # image. Launching exercises exactly what the specs do. PW_VERSION="$(cd "$REPO_ROOT/web" && node -p "require('@playwright/test/package.json').version")" # mktemp, not a predictable /tmp/...$$ path: on a shared host a pre-created symlink at a guessable # name would redirect this write. PW_PROBE_ERR="$(mktemp)" if ! (cd "$REPO_ROOT/web" && node -e "require('@playwright/test').chromium.launch().then(b=>b.close())") 2>"$PW_PROBE_ERR"; then echo "error: could not launch headless chromium for @playwright/test ${PW_VERSION}:" >&2 sed 's/^/ /' "$PW_PROBE_ERR" >&2 || true rm -f "$PW_PROBE_ERR" echo " In CI this means the browser baked into the toolchain image no longer matches" >&2 echo " web/package.json's @playwright/test pin (${PW_VERSION}): bump PLAYWRIGHT_VERSION in" >&2 echo " docker/ci/Dockerfile to match, let ci-image.yml publish the new :, then update" >&2 echo " all five container pins in docker-build.yml (docs/ci-cd.md -> 'CI toolchain image')." >&2 echo " Locally: cd web && npx playwright install chromium" >&2 exit 1 fi rm -f "$PW_PROBE_ERR" # No port pre-flight here: `scripts/e2e-local.sh` owns that since ersatztv#586/#598, and its version # is strictly better — it probes BOTH bound ports (the app binds a second, streaming listener that # otherwise defaults to 8409 regardless of ETV_UI_PORT) and it REPORTS rather than reaps. Duplicating # it here would mean two divergent checks with different messages on the same concern. # # Note the app also enforces a SINGLE-INSTANCE guard of its own ("Another instance of ErsatztTV is # already running"), independent of ports — so a stray instance anywhere blocks a run regardless of # which port you pick. Kill the leftover BY PID; never `pkill -f dotnet ErsatzTV.dll` (#586). # --- Server lifecycle ------------------------------------------------------------------------- # # The trap is installed BEFORE the server is booted, not after. A signal (or any early exit) # arriving while `e2e-local.sh` is still starting the app would otherwise leave a live server bound # to the port with no handler to reap it — precisely the "stray process" failure this script exists # to prevent. # # `wait "$PID"` cannot be used to confirm the kill: e2e-local.sh launches the app inside its own # subshell and then exits, so the process is NOT a child of THIS shell — `wait` fails instantly # ("not a child of this shell") and returns before the port is released. Poll `kill -0` instead, # then escalate to SIGKILL. SERVER_PID="" # e2e-local.sh writes the server PID here the instant it forks, BEFORE its readiness wait. That # closes the mid-boot window: if this script is signalled while the app is still starting, we have # never parsed stdout, but the pidfile already names the process. # # This replaces an earlier "kill whatever LISTENS on $PORT" fallback, which was wrong on two counts: # it needed `lsof` (ABSENT from the CI toolchain image — verified — so it silently no-opped exactly # where it was needed), and it inferred ownership from the pre-flight instead of proving it, so a # process that grabbed the port after the pre-flight — or a real instance on a shared host — could be # killed. A pidfile we asked for proves ownership outright and needs no external tool. PIDFILE="$(mktemp)" export ETV_PIDFILE="$PIDFILE" # Reap $1 with a bounded graceful wait, then SIGKILL. `wait` is unusable here: the server is not a # child of THIS shell (e2e-local.sh forked it and exited), so `wait` fails instantly and would return # before the port is released. reap() { local pid="$1" kill "$pid" 2>/dev/null || true local i=0 while [ "$i" -lt 50 ]; do # up to ~5s kill -0 "$pid" 2>/dev/null || return 0 sleep 0.1 i=$((i + 1)) done echo "warning: server $pid did not exit after SIGTERM; sending SIGKILL" >&2 kill -9 "$pid" 2>/dev/null || true } read_pidfile() { [ -s "$PIDFILE" ] || return 0 tr -dc '0-9' <"$PIDFILE" 2>/dev/null || true } cleanup() { # ORDER MATTERS. Read the pidfile BEFORE touching the launcher: killing e2e-local.sh first could # land in the window between its fork and its pidfile write, losing the only handle on a server # that is already running. local pid="$SERVER_PID" [ -n "$pid" ] || pid="$(read_pidfile)" # Now stop the backgrounded launcher so it can't keep booting/waiting on a server we're killing. # Best-effort only — it is never the primary handle on the server (see the grace re-read below). if [ -n "${BOOT_PID:-}" ]; then kill "$BOOT_PID" 2>/dev/null || true BOOT_PID="" fi rm -f "${BOOT_OUT:-}" 2>/dev/null || true # Grace re-read: a signal can land in the sliver between e2e-local.sh forking the server and # publishing its PID (or between `... &` and this script's `BOOT_PID=$!`). In those cases the # pidfile is empty at first read but populated microseconds later. Without this, that server is # orphaned. Bounded at ~1s so a genuinely server-less abort still exits promptly. if [ -z "$pid" ]; then local i=0 while [ "$i" -lt 20 ]; do pid="$(read_pidfile)" [ -n "$pid" ] && break sleep 0.05 i=$((i + 1)) done fi SERVER_PID="" rm -f "$PIDFILE" [ -n "$pid" ] || return 0 reap "$pid" } # Re-raise INT/TERM after cleaning up, so the wrapper dies BY the signal rather than exiting 0. # Without this a TERM landing next to a passing Playwright run reports success for a cancelled run. on_signal() { cleanup trap - EXIT "$1" kill -"$1" $$ } trap cleanup EXIT trap 'on_signal INT' INT trap 'on_signal TERM' TERM echo "Booting instance for UI-E2E (config: $CONFIG_DIR, port: $PORT)..." # Boot in the BACKGROUND and `wait` on it, rather than the obvious # `OUT="$(... e2e-local.sh ...)"`. Bash defers a trapped signal until the current FOREGROUND command # finishes, so with a command substitution a TERM arriving during the (up to 120s) readiness wait # would not run `on_signal` until boot completed — and a supervisor that escalates TERM->KILL would # mean the trap never runs at all, orphaning the server on its port. `wait` is interruptible: the # handler runs immediately, and by then e2e-local.sh has already written $ETV_PIDFILE, so cleanup # can reap a server whose PID this script has not yet parsed. BOOT_OUT="$(mktemp)" ETV_UI_PORT="$PORT" "$REPO_ROOT/scripts/e2e-local.sh" "$CONFIG_DIR" >"$BOOT_OUT" 2>&1 & BOOT_PID=$! set +e wait "$BOOT_PID" boot_status=$? set -e # Clear it the moment `wait` reaps the launcher. A stale PID here is not merely useless — the number # can be RECYCLED by the OS during a long spec run, and cleanup would then SIGTERM an unrelated # process. (Same "never kill something you didn't prove is yours" rule that removed the earlier # port-based reap.) BOOT_PID="" OUT="$(cat "$BOOT_OUT")" rm -f "$BOOT_OUT" if [ "$boot_status" -ne 0 ]; then echo "error: e2e-local.sh failed to boot the instance (exit $boot_status). Its output:" >&2 printf '%s\n' "$OUT" >&2 exit "$boot_status" fi printf '%s\n' "$OUT" SERVER_PID="$(printf '%s\n' "$OUT" | awk -F= '/^PID=/{print $2}')" SERVER_LOG="$(printf '%s\n' "$OUT" | awk -F= '/^LOG=/{print $2}')" if [ -z "$SERVER_PID" ]; then echo "error: could not determine the server PID from e2e-local.sh output" >&2 exit 1 # the EXIT trap's port-based fallback reaps the server we just started fi echo "Running Playwright UI-E2E specs against http://localhost:${PORT} ..." cd "$REPO_ROOT/web" # Capture the status WITHOUT `if ! cmd; then` — under `!` negation bash sets `$?` to the *logical # negation*, so `$?` reads 0 inside the failure branch and the script would `exit 0` on a FAILING # spec run, silently passing CI. (Verified: `if ! (exit 42); then echo $?; fi` prints 0.) `set +e` # around the call, then read `$?` directly, is unambiguous. set +e ETV_BASE_URL="http://localhost:${PORT}" npx --no-install playwright test "$@" status=$? set -e if [ "$status" -ne 0 ]; then # No workflow in this repo uploads artifacts, so traces/screenshots in web/e2e/.output never leave # the runner. The server log is the only server-side evidence a CI failure would otherwise lack. if [ -n "${SERVER_LOG:-}" ] && [ -f "$SERVER_LOG" ]; then echo "--- server log tail ($SERVER_LOG) ---" >&2 tail -n 40 "$SERVER_LOG" >&2 || true echo "--- end server log ---" >&2 fi fi exit "$status"