Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m18s
The fix commit softened the script's scan-409 wording + dropped ?deep=true but left the docs describing the old behavior. Match them: - docs/e2e-local.md: POST .../scan (no ?deep; note local scans always ForceScan) + "409, deterministic bar a tiny residual TOCTOU gap" (was "guaranteed 409") - docs/ci-cd.md: same "guaranteed 409" -> "409 (deterministic bar ...)" Docs-only; harness behavior unchanged (still 38/38 green, deterministic). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
250 lines
16 KiB
Markdown
250 lines
16 KiB
Markdown
# 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 2–4 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. Most assertions are curl-only (no seeded media / ffmpeg / browser); the
|
||
**lock-contention** section (added by ersatztv#363) is the exception — it seeds DB rows + media.
|
||
- **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).
|
||
- **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`.
|
||
- **Lock contention (409)** — the two `IEntityLocker` 409s, made **deterministic** by firing the
|
||
racing request only once the lock is *provably* held (never a sleep-and-hope). Both seed rows the
|
||
API can't create straight into the running instance's DB via python3's stdlib `sqlite3` (whose
|
||
busy-timeout retry serializes behind the app's writer):
|
||
- **library-scan "already scanning"**: seed ~60 tiny ffmpeg clips into the built-in Shows library
|
||
(Id=2) so the scanner subprocess runs a few seconds → `POST .../scan` `202` (local scans always
|
||
ForceScan, so no `deep` needed) → poll `GET /libraries/scan-status` until library 2 shows active
|
||
(that window is a strict subset of the scan lock's held window — `ScannerProxyService.StartScan`
|
||
fires *after* `LockLibrary`, `EndScan` *before* `UnlockLibrary`) → a second `POST .../scan` is a
|
||
`409`, deterministic bar a tiny residual TOCTOU gap the multi-second scan covers. Self-skips
|
||
(advisory) if ffmpeg is absent.
|
||
- **external-collections "already scanning"**: seed a Jellyfin media-source row pointing at a
|
||
non-routable address (so the background sync hangs and the per-family lock stays held). The lock
|
||
is taken synchronously *before* the `202`, so the `202` proves it held → `collections-scan-status`
|
||
shows the `jellyfin` family → a second `POST .../scan-collections` is `409`; unknown source `404`.
|
||
Needs neither ffmpeg nor the scanner.
|
||
- **Auth/CSRF/security-stamp** (mirrors the #295 manual set; runs **last** — setup-claim is one-shot
|
||
and logout revokes the session): 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).
|
||
|
||
**Still deferred** (ersatztv#363 follow-ups): the **playout-build lock 409 + `isLocked` projection**
|
||
(#215) — needs a seeded schedule/playout and a build window wide enough to observe without racing —
|
||
and the genuinely **UI-interactive Playwright** flows (a separate CI browser-tooling lift). 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; the #363
|
||
cut measured the real scan window before sizing the seed).
|
||
|
||
## 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.)
|