Files
ersatztv/scripts/e2e-local.sh
T
timothyandClaude Opus 5 c8e79f49f4
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m55s
chore(586,594,485): PID-scoped E2E cleanup, ci-image-pin length guard, .gitignore core fix
Three independent CI/repo-hygiene fixes swept together; disjoint file sets.

fixes #586 — E2E cleanup is scoped by PID, never a pattern-wide pkill
  - New decision record `testing.e2e-cleanup-scope-by-pid`.
  - docs/e2e-local.md states the constraint where a BRIEF-WRITER sees it (the
    #586 root cause was a delegation gap, not agent error).
  - scripts/e2e-local.sh: reviewed against e2e-ui.sh's trap lifecycle and
    deliberately does NOT adopt it — its contract is to hand a running instance
    back to its caller, so an EXIT trap would kill the server the instant the
    launcher returned (both callers use `OUT="$(e2e-local.sh ...)"`). Recorded.
  - Instead it gains what actually prevents the incident: an lsof pre-flight
    that NAMES a foreign listener's PID rather than letting Kestrel fail its
    bind and surface as "process N exited before becoming ready".
  - Pre-flight probes BOTH bound ports, and ETV_STREAMING_PORT now defaults to
    ETV_UI_PORT. Program.cs binds a second listener whose port defaults to 8409
    independently of ETV_UI_PORT, so `ETV_UI_PORT=8420` alone still bound 8409
    and died against a foreign holder — i.e. the documented escape hatch was a
    dead end that led straight back to the confusion behind the pattern kill.

fixes #594 — ci-image-pin accepts any hex length
  - Length is a separate invariant from correctness: the resolve/staleness
    checks compare resolved shas, so an 8-char pin of the right commit passes
    green while matching NO registry tag, and all five container: jobs then die
    at image-pull with `manifest unknown` (reads like a registry outage).
  - Guard fails at the gate and prints the exact tag to use. Verified against
    doctored pins: 7 green; 6/8/10 red.
  - Uses a literal 7 rather than a derived `--short=7`: in a full clone git may
    widen an ambiguous abbreviation, demanding a pin ci-image.yml can never
    publish. Escape hatch documented inline.
  - Also fixes a pre-existing misdiagnosis: zero pins reported "MORE THAN ONE".
  - docs/ci-cd.md documents the 7-char rule and `git rev-parse --short=7 HEAD`.

fixes #485 — .gitignore `core` silently ignored `*/Core/` files
  - A bare `core` matched any path component named `core`; case-insensitively
    on macOS that swallowed every `*/Core/` SOURCE dir, so new untracked files
    were dropped by `git add -A` while tracked ones stayed fine — a clean local
    build and a CI checkout that fails to compile.
  - Now `/core` + `/core.[0-9]*`, both anchored (an unanchored `core.[0-9]*`
    would re-introduce the same silent-exclusion class this fixes).
  - Verified by diffing the full ignored-file set before/after: identical, and
    the three real Core/ dirs are trackable without -f.

Docs updated in-PR: docs/e2e-local.md, docs/ci-cd.md, docs/decisions/
workflow-process.md (+ regenerated catalog), docs/handoffs/chicorytv-issue-queue.md.

Follow-ups filed: #596 (the same shared-host reap in the Playwright-MCP
recovery record) and the ci-image.yml `--short=7` publisher-side fix, which
cannot ride this PR — editing ci-image.yml re-points ci-image-pin's `expected`
at this commit and reds the gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:46:13 +02:00

147 lines
7.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# scripts/e2e-local.sh — launch a local ErsatzTV instance for live E2E verification.
#
# See docs/e2e-local.md for the full recipe and the "why" behind each step. This script assumes
# `dotnet build ErsatzTV.sln` and (if the SPA changed) `cd web && npm run build` have ALREADY been
# run — it only does the wwwroot copy + launch + ready-wait, since rebuilding on every invocation
# is slow and this is meant to be re-run often during a debugging session.
#
# Usage:
# scripts/e2e-local.sh [CONFIG_DIR]
#
# CONFIG_DIR defaults to a fresh `mktemp -d` if omitted. NEVER reuse a config dir across runs.
#
# On success, prints:
# PID=<pid>
# PORT=<port>
# CONFIG_DIR=<dir>
# LOG=<log file path>
# and exits 0, leaving the server RUNNING in the background.
#
# THE CALLER OWNS THE PRINTED PID. Stop the server with `kill "$PID"` using the PID printed above,
# and free the port before starting another run.
#
# NEVER `pkill -f "dotnet ErsatzTV.dll"` (ersatztv#586, `testing.e2e-cleanup-scope-by-pid`). This
# machine is shared by parallel sessions running this same binary, and a pattern kill reaps all of
# them — silently truncating another run's output into plausible-but-wrong data rather than failing
# loudly. Choosing a different port does NOT make a pattern kill safe: it matches on the command
# line, not the port. Kill the PID you started; if some other process holds the port, report it and
# move to `ETV_UI_PORT=<other>` (the pre-flight below prints the offending PID for you, and also
# moves the streaming listener — see the ETV_STREAMING_PORT note below, which is why that actually
# works here but not when you launch the DLL by hand).
#
# This script deliberately does NOT trap-and-kill on exit, unlike scripts/e2e-ui.sh: its contract is
# to hand a running instance back to its caller, so an EXIT trap would kill the server the moment the
# launcher returned. e2e-ui.sh is the lifecycle OWNER and traps; this is the launcher and does not.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
# ETV_BUILD_CONFIG selects which build output to launch (Debug for local dev; the CI functional-E2E
# job builds Release). Must match the `dotnet build --configuration` you ran beforehand.
BUILD_CONFIG="${ETV_BUILD_CONFIG:-Debug}"
BUILD_DIR="$REPO_ROOT/ErsatzTV/bin/$BUILD_CONFIG/net10.0"
PORT="${ETV_UI_PORT:-8409}"
# The app binds TWO listeners: Program.cs does `ListenAnyIP(UiPort)` and, when they differ,
# `ListenAnyIP(StreamingPort)` — and SystemEnvironment.cs defaults StreamingPort to 8409
# INDEPENDENTLY of ETV_UI_PORT. So `ETV_UI_PORT=8420` alone still binds 8409 and dies at startup if
# another session holds it, which is the exact dead end that produced the #586 pattern-kill. Default
# the streaming port to whatever port this run was given so "move to a free port" actually works; an
# explicit ETV_STREAMING_PORT still wins. CI sets ETV_UI_PORT=8409, so this is a no-op there.
export ETV_STREAMING_PORT="${ETV_STREAMING_PORT:-$PORT}"
# Reject a non-numeric port rather than let it fail silently in the WORST possible way: `lsof -ti :abc`
# fails a service-name lookup so the pre-flight below skips it, and SystemEnvironment.cs's int.TryParse
# then falls back to 8409 — so a typo'd port makes the app bind the very port the pre-flight just
# certified as irrelevant, and the run dies against the foreign holder with exactly the confusing
# framing this script exists to prevent.
for port_var in ETV_UI_PORT:"$PORT" ETV_STREAMING_PORT:"$ETV_STREAMING_PORT"; do
case "${port_var#*:}" in
''|*[!0-9]*)
echo "error: ${port_var%%:*} must be a number, got '${port_var#*:}'. A non-numeric port is" >&2
echo " silently ignored by the app, which then falls back to binding 8409." >&2
exit 1
;;
esac
done
READY_LINE="Done migrating search index"
TIMEOUT_SECS=120
# Pre-flight (before mktemp, so a refused run leaks no config dir): name the process holding the
# port rather than letting this fail at startup with `process N exited before becoming ready` and a
# log tail — a framing that reads like a broken build instead of "something else is already
# listening" (ersatztv#586). A foreign listener is REPORTED, never reaped: on a shared machine it is
# very likely another session's harness mid-run. Both bound ports are probed, since a free UI port
# with a busy streaming port fails just as hard. `command -v` guard: lsof is absent from some
# containers, and this diagnostic must never itself be the reason a run fails.
if command -v lsof >/dev/null 2>&1; then
for probe_port in $(printf '%s\n%s\n' "$PORT" "$ETV_STREAMING_PORT" | sort -u); do
if lsof -ti :"$probe_port" >/dev/null 2>&1; then
echo "error: port $probe_port is already in use by PID(s): $(lsof -ti :"$probe_port" | tr '\n' ' ')" >&2
echo " That process is NOT yours. Do NOT 'pkill -f \"dotnet ErsatzTV.dll\"' — on this shared" >&2
echo " machine that reaps other sessions' servers mid-run (ersatztv#586)." >&2
echo " Re-run on a free port instead. Set BOTH, or the app still binds 8409:" >&2
echo " ETV_UI_PORT=8420 ETV_STREAMING_PORT=8420 $0${1:+ \"$1\"}" >&2
echo " (this script defaults ETV_STREAMING_PORT to ETV_UI_PORT, so ETV_UI_PORT=8420 alone" >&2
echo " is enough when you invoke it directly — set both when launching the app by hand.)" >&2
exit 1
fi
done
fi
CONFIG_DIR="${1:-$(mktemp -d)}"
mkdir -p "$CONFIG_DIR"
if [ ! -d "$BUILD_DIR" ]; then
echo "error: $BUILD_DIR does not exist — run 'dotnet build ErsatzTV.sln${BUILD_CONFIG:+ --configuration $BUILD_CONFIG}' first" >&2
exit 1
fi
if [ ! -d "$REPO_ROOT/ErsatzTV/wwwroot/app" ]; then
echo "warning: $REPO_ROOT/ErsatzTV/wwwroot/app does not exist — the SPA hasn't been built" \
"('cd web && npm run build'). The /app UI will 404 until it exists AND the server is" \
"(re)started after copying it in." >&2
fi
echo "Copying wwwroot into build output (static middleware resolves its file root at startup;" \
"a running process will never see files added later)..."
# rm first: with an existing destination dir, `cp -R src dst` copies INTO it (dst/wwwroot/...),
# silently leaving a previous run's stale assets in place.
rm -rf "$BUILD_DIR/wwwroot"
cp -R "$REPO_ROOT/ErsatzTV/wwwroot" "$BUILD_DIR/wwwroot"
LOG_FILE="$(mktemp)"
echo "Launching dotnet ErsatzTV.dll (log: $LOG_FILE, config: $CONFIG_DIR, port: $PORT)..."
(
cd "$BUILD_DIR"
ETV_CONFIG_FOLDER="$CONFIG_DIR" ETV_UI_PORT="$PORT" ETV_STREAMING_PORT="$ETV_STREAMING_PORT" dotnet ErsatzTV.dll
) >"$LOG_FILE" 2>&1 &
PID=$!
echo "Waiting up to ${TIMEOUT_SECS}s for '$READY_LINE'..."
elapsed=0
until grep -q "$READY_LINE" "$LOG_FILE" 2>/dev/null; do
if ! kill -0 "$PID" 2>/dev/null; then
echo "error: process $PID exited before becoming ready. Log tail:" >&2
tail -n 40 "$LOG_FILE" >&2
exit 1
fi
if [ "$elapsed" -ge "$TIMEOUT_SECS" ]; then
echo "error: timed out after ${TIMEOUT_SECS}s waiting for readiness. Log tail:" >&2
tail -n 40 "$LOG_FILE" >&2
kill "$PID" 2>/dev/null || true
exit 1
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "Server ready."
echo "PID=$PID"
echo "PORT=$PORT"
echo "CONFIG_DIR=$CONFIG_DIR"
echo "LOG=$LOG_FILE"