# 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). ## 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 — no API key is required for local mutating requests by default (`ApiKeyAuthorizationFilter` only enforces the `X-Api-Key` header when `Api:WriteKey` is configured; it's empty/unset in a fresh local config, so writes are open). 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": , "scheduleKind": "Block"}` (or `"Classic"`/`"Scripted"`/`"Sequential"` per `ChannelPlayoutSource`/schedule-kind enums — check `v1.json` for the current set). 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/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 `). ## 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/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 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/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.)