Files
ersatztv/docs/e2e-local.md
T
timothyandClaude Opus 4.8 461c763dc6 docs: #295 PR2 + #301 — decisions entry, api-conventions §9, e2e-local browser flow
- decisions.md: new entry (SPA cookie-only cutover, boot-gate-not-route, #301
  POST-ification rationale, machine-key-read + OIDC-logout residual) + TOC line.
- api-conventions §9: #301 resolved (POST-ify) + 'never add a side-effecting GET'
  standing rule; machine-key endpoint added to the auth surface list; PR2-shipped note.
- e2e-local: fix stale 'no key required' claim (fail-closed since #197) + browser
  setup/login boot-gate flow.
(spa-conventions §5e rewrite landed with the SPA-consumers slice.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:42 +02:00

12 KiB
Raw Blame History

Local live-E2E recipe

Purpose: how to stand up a real, running instance of this fork locally (dotnet host + built SPA) for manual or Playwright-MCP-driven end-to-end verification — no live Docker/prod dependency. Update this doc (and scripts/e2e-local.sh) in the same PR that changes any convention below.

This is for interactive/agent-driven verification, not CI (docs/ci-cd.md covers the CI pipeline, which never runs the app itself).

When live-E2E is required (not optional)

A live-E2E pass through this recipe is a required step — not a nicety — for any PR that changes an API write-path handler: a POST/PUT/DELETE /api/* command that mutates state and then reloads it through the read path. Reason: this class has a correlated blind spot that unit and characterization tests share. A green fixed-point test passed while a write-path returned a 500 in production because the handler returned a lazy LanguageExt Map the test never enumerated (#229, PR — see docs/decisions.md and api-conventions.md §7); the reload-through-read-path mechanics can throw only when the result is actually materialised, which the SPA does and the test did not. Live driving the real screen (or curling the real endpoint) is the only net that reliably catches it.

Concretely, for a write-path PR: stand up the instance (Steps below), exercise the changed create/update/delete flow against the real endpoint or its SPA screen, and confirm the mutation round-trips through a subsequent read (list/detail/browse) — not just that the write returned 2xx. Pure-SPA/read-only or docs PRs don't need it. State in the PR/close comment that live-E2E ran (or, for a non-write-path change, that it wasn't required) — the same auditable-exemption rule the review skip rubric uses (kickoff workflow lore).

Why the steps are in this order

  • ErsatzTV/Startup.cs's /app SPA middleware resolves its static-file root once, at startup (SpaStaticFileRoot(), checked with Directory.Exists and swapped to a NullFileProvider if missing — see Startup.cs around the app.MapWhen(... "/app" ...) block). If wwwroot/app doesn't exist yet when the process starts, the SPA will 404 forever until you restart the process — copying the built files in after the fact does nothing for an already-running instance.
  • The dotnet host and the SPA share one port (default 8409, ETV_UI_PORT / SystemEnvironment. UiPort in ErsatzTV.Core/SystemEnvironment.cs) — there's no separate dev server/proxy in this workflow; you're testing the actual production static-hosting path.
  • A fresh config folder per run avoids state bleed (leftover channels/schedules/DB) between test sessions corrupting your assertions.

Steps

  1. Build the prerequisites (once, or after any source change):

    dotnet build ErsatzTV.sln
    cd web && npm run build && cd ..   # → ErsatzTV/wwwroot/app (vite.config.ts outDir)
    
  2. Copy wwwroot into the build output (the dotnet build output directory does not automatically pick up web/'s build artifacts placed directly into the source wwwroot):

    rm -rf ErsatzTV/bin/Debug/net10.0/wwwroot
    cp -R ErsatzTV/wwwroot ErsatzTV/bin/Debug/net10.0/wwwroot
    

    The rm matters: if the destination dir already exists (any prior run), cp -R src dst copies into it (dst/wwwroot/...) and the server silently keeps serving the previous run's stale assets. scripts/e2e-local.sh does the rm+copy for you. If you rebuild the SPA (npm run build) while the dotnet process from step 4 is already running, re-copy and then restart the process — see the "why" note above; it will not pick up new files live.

  3. Use a fresh scratch config folder — never reuse one across test runs:

    CONFIG_DIR=$(mktemp -d)
    
  4. Run the app, pointed at the scratch folder:

    cd ErsatzTV/bin/Debug/net10.0
    ETV_CONFIG_FOLDER="$CONFIG_DIR" dotnet ErsatzTV.dll
    

    Wait for the log line Done migrating search index (emitted by RebuildSearchIndexHandler in ErsatzTV.Application/Search/Commands/, with a ... in {Duration} suffix) — that's the last long-running startup step; before that, requests may 404/error. The UI and the entire /api/* surface are served on the same port, 8409 by default (override with ETV_UI_PORT).

  5. Seed data as needed via the API. The /api surface is fail-closed since #197 (writes always, and reads because Api:RequireKeyForReads defaults true), so curl seeding must send the machine key the server generates at startup: -H "X-Api-Key: $(cat "$CONFIG_DIR/api.key")". (Mutations from a session also need -H 'X-CSRF: 1', but for scripted seeding the machine key is simpler and CSRF-immune.) Examples:

    • Create a channel: POST /api/channels — check the current ErsatzTV/wwwroot/openapi/v1.json (or CreateChannelRequest.cs) for the exact required field list before assuming these are complete, but as of this writing it requires (among plain fields) these enums: PlayoutSource: "Generated", PlayoutMode: "Continuous", SongVideoMode: "Default", TranscodeMode: "OnDemand", IdleBehavior: "StopOnDisconnect".
    • Create a playout for an existing channel: POST /api/playouts with {"channelId": <id>, "scheduleKind": "Block"} (or "Classic"/"Scripted"/"Sequential" per ChannelPlayoutSource/schedule-kind enums — check v1.json for the current set).

5b. Browser flow (since #295 session auth): the first /app load hits a boot gate. On a fresh config it shows Setup — claim the local admin (pick any username/password); that issues the session cookie and drops you into the app. On a subsequent run of the same config it shows Login instead. (To skip the browser claim in automation, set Auth:LocalAdmin:Password before launch — the env seed provisions the admin and disables the browser setup-claim.) The machine key still works for curl seeding regardless. Driving this with Playwright: fill the Setup/Login form before asserting any authed screen.

  1. Tear down: kill the dotnet ErsatzTV.dll process and confirm the port is freed (lsof -i :8409 should return nothing) before starting another run — a stray process holding the port will make the next run's health check hang or fail confusingly.

Playwright MCP screenshots

If you're driving the browser via the Playwright MCP server for visual verification, screenshots land in the MCP server process's own cwd (this repo's root, not wherever you ran the dotnet process from) — expect stray *.png files at the repo root after a session; this is tolerated, not a bug to fix, but don't check them in.

Script: scripts/e2e-local.sh

A copy of this script is included in this doc's directory; it is intended to land at scripts/e2e-local.sh in the repo. It automates steps 24 above (build is assumed already done — run dotnet build / npm run build yourself first, since rebuilding on every invocation is slow and this script 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.
  • Copies ErsatzTV/wwwrootErsatzTV/bin/Debug/net10.0/wwwroot.
  • Launches dotnet ErsatzTV.dll in the background with ETV_CONFIG_FOLDER set.
  • Waits (up to 120s) for the Done migrating search index log line.
  • Prints the PID and port, then exits leaving the server running — the caller is responsible for killing the PID when done (kill <PID>).

Seeding a local TV library for E2E

Channels/playouts are API-seedable (step 5 above), but a local media library is not — there is no /api/* endpoint to add a local library folder. To exercise media-browse / search / detail screens you need real scanned items. Recipe (used to verify the #220 episode-nav PR):

  1. Generate tiny media files on disk — one show (one season, ~3 episodes), plus a second show whose title contains the first as a substring (good substring-search sanity data), plus a movie if you need a non-episode kind. Keep TV and movies under separate roots so each library scans cleanly (a Shows library pointed at a folder that also contains movies will try to parse the movies as shows). Each file is a 2-second testsrc clip:

    MEDIA=/tmp/etv-media    # any scratch path
    mkdir -p "$MEDIA/tv/Show Alpha/Season 01" \
             "$MEDIA/tv/Show Alpha Returns/Season 01" \
             "$MEDIA/movies/Test Movie (2020)"
    for n in 01 02 03; do
      ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \
        "$MEDIA/tv/Show Alpha/Season 01/Show Alpha - s01e$n.mkv"
    done
    ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \
      "$MEDIA/tv/Show Alpha Returns/Season 01/Show Alpha Returns - s01e01.mkv"
    ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \
      "$MEDIA/movies/Test Movie (2020)/Test Movie (2020).mkv"
    
  2. Attach the folders to the built-in local libraries via SQLite. A fresh config DB already has the seven default local libraries (Library rows for a single LocalMediaSource): Movies is Id=1, Shows is Id=2. LibraryPath is just (Path TEXT, LibraryId INT) — insert one row per root, pointing each at the matching library:

    DB="$CONFIG_DIR/ersatztv.sqlite3"    # CONFIG_DIR from the run above; server may be running
    sqlite3 "$DB" "INSERT INTO LibraryPath (Path, LibraryId) VALUES ('$MEDIA/tv', 2);"     # Shows
    sqlite3 "$DB" "INSERT INTO LibraryPath (Path, LibraryId) VALUES ('$MEDIA/movies', 1);" # Movies
    
  3. Trigger a scan and wait for items to appear. The scan endpoint takes an empty body:

    curl -s -X POST http://localhost:8409/api/libraries/2/scan -H 'Content-Type: application/json' -d '{}'
    curl -s -X POST http://localhost:8409/api/libraries/1/scan -H 'Content-Type: application/json' -d '{}'
    # poll until episodes show up (scanner runs as a background subprocess):
    curl -s "http://localhost:8409/api/library/browse?mediaType=Episode&pageSize=50"
    

    The scan runs even though LibraryPath was inserted after startup — the scan handler re-reads the library from the DB. Browse (/api/library/browse) reads straight from the DB, so items appear there within a few seconds.

Gotchas

  • Do NOT delete the search-index/ folder to "reset" search. On startup the app recreates the index empty (Search index failed to initialize; will delete and recreateMigrating search index to version N) and that migration does not re-index from the DB — only a scan writes documents into the Lucene index. The scanner subprocess writes the index while running; a restart never rebuilds it from existing DB rows. If you wipe search-index/, a rescan of unchanged files won't repopulate it (the scanner skips unchanged items), so search stays empty. The clean recovery is a fresh CONFIG_DIR: launch → insert LibraryPath → scan once → leave the index alone.
  • Search query relevance is field-scoped, not free-text. The /api/search default field does not match bare title words: Alpha and Show return nothing for a "Show Alpha" title, while title:Alpha, Show*, or *Alpha* all match. The SPA search box forwards the query verbatim, so when driving search-result screens in E2E use a field/wildcard query (e.g. title:Alpha) to get deterministic hits. (This is pre-existing ErsatzTV search behavior, independent of any SPA change.)