Files
ersatztv/docs/e2e-local.md
T
timothyandClaude Opus 4.8 0d7803079c
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m54s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 18s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 4m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m22s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m38s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 6m2s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m41s
ci: add advisory functional-E2E curl harness (fixes #299)
Codify the manual live-E2E curl flows sessions have been re-running by hand
into a CI regression net: a new `functional-e2e` job boots the app from source
(via scripts/e2e-local.sh, parameterized for Release) and drives scripts/
e2e-functional.sh against it.

First-cut contracts (all curl-only, deterministic, no seeded media/ffmpeg/browser):
- legacy->SPA redirect sweep + the /api,/artwork never-redirect exemption
- auth/CSRF/security-stamp flow (setup-claim, read-gate, CSRF, login, logout+revoke)
- library-scan status contract (404/202/scan-status)
- optimistic-concurrency If-Match/412 round-trip

Advisory by design (separate job, not a `build` dependency, not a required
check) so a functional-E2E flake can't block the unit-test gate; promote once
proven, mirroring the migrations-job rollout. SQLite default -> no DB service.

Deferred to #299 follow-ups (need scanner+seeded media or a browser to be
deterministic): the racy 409 re-trigger, playout-build lock 409, Playwright UI
flows.

Assertions verified 30/30 green against a real Release-built instance; caught
/artwork/* returning 400 (not the 404 a static read suggested).

Docs updated same PR: docs/ci-cd.md (new job), docs/e2e-local.md (harness),
docs/decisions.md (append-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:49:33 +02:00

231 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `curl`ing 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):
```bash
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`):
```bash
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:
```bash
CONFIG_DIR=$(mktemp -d)
```
4. **Run the app**, pointed at the scratch folder:
```bash
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/v1/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/v1/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.
6. **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:
```bash
scripts/e2e-local.sh [CONFIG_DIR]
```
- `CONFIG_DIR` defaults to a fresh `mktemp -d` if omitted.
- Copies `ErsatzTV/wwwroot` → `ErsatzTV/bin/<config>/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>`).
- **`ETV_BUILD_CONFIG`** selects which build output to launch (`Debug` default for local dev; the
CI `functional-e2e` job sets `Release`). It must match the `dotnet build --configuration` you ran
first — the script only copies `wwwroot` + launches; it does not build.
## Functional-E2E harness: `scripts/e2e-functional.sh`
The ad-hoc curl scenarios sessions run against a live instance (redirect sweeps, auth flows, the scan
status contract, `If-Match`/412) are codified into a single harness so they run identically by hand
and in CI (the advisory `functional-e2e` job — `docs/ci-cd.md`). It does **not** boot the app; pair it
with `scripts/e2e-local.sh` above (or point it at any running instance):
```bash
CFG=$(mktemp -d)
eval "$(scripts/e2e-local.sh "$CFG" | sed -n 's/^\(PID\|PORT\)=/\1=/p')" # launch, capture PID/PORT
scripts/e2e-functional.sh "http://localhost:${PORT}" "$CFG" # assert; exit 1 on any failure
kill "$PID"
```
`CONFIG_DIR` (arg 2) is **required** — the harness reads the instance's machine key from
`$CONFIG_DIR/api.key` and sends `X-Api-Key` on every `/api` call (the surface is fail-closed). It runs
each assertion even after a failure and prints a pass/fail summary, exiting non-zero if any failed.
What it covers (all curl-only, deterministic, no seeded media / ffmpeg-transcode / browser needed):
- **Legacy→SPA redirects**: a sweep of representative `LegacyUiRedirects` routes 302→`/app/*`, plus
the `/api` + `/artwork` never-redirect exemption (asserted as "did not 302 to `/app`", since those
4xx from their own handlers — `/api/*` 404s, `/artwork/*` 400s).
- **Auth/CSRF/security-stamp** (mirrors the #295 manual set): fresh `auth/config` `setupRequired:true`
→ read-gate 401-without-key / 200-with-key → setup-claim 200 → re-claim 409 → session mutation
403-without-`X-CSRF` → login 401-then-200 → logout 403-without-CSRF / 204-with → post-logout the
same cookie is 401 (rotated security stamp).
- **Library-scan status contract**: create an empty local library → scan `202`, unknown-library scan
`404`, `scan-status` `200`.
- **Optimistic concurrency**: create a collection + a `rerun-collections` targeting it → `GET` emits
an `ETag` → `PUT` with a stale `If-Match` `412`, current `200`, malformed `400`.
**Deliberately deferred** (need the scanner subprocess + seeded media, or a browser, to be
deterministic — ersatztv#299 follow-ups): the 409 "already-scanning" re-trigger, the playout-build
lock 409, and the genuinely UI-interactive Playwright flows. When you extend the harness, add the
observed contract here and keep the assertions deterministic (probe the real instance first — the
initial cut caught that `/artwork/*` 400s where a guess said 404).
## 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:
```bash
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:
```bash
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:
```bash
curl -s -X POST http://localhost:8409/api/v1/libraries/2/scan -H 'Content-Type: application/json' -d '{}'
curl -s -X POST http://localhost:8409/api/v1/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/v1/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/v1/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 recreate` → `Migrating 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/v1/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.)