feat(445,533): headless Playwright UI-E2E flows + fix e2e-local readiness probe #591

Merged
timothy merged 13 commits from feat/445-533-ui-e2e into main 2026-07-25 14:52:33 +02:00
18 changed files with 860 additions and 45 deletions
+28 -7
View File
@@ -29,7 +29,7 @@ name: Build ErsatzTV Image
# cannot read the workflow `env` context. **Bump all five together**; see docs/ci-cd.md ->
# "CI toolchain image" for the two-step procedure.
#
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
#
# DOCS-ONLY SKIP (ersatztv#416): a change that touches only docs/** or *.md has nothing for the
# heavy jobs to validate. `test`, `migrations`, `functional-e2e` and `build` each run
@@ -107,7 +107,7 @@ jobs:
name: Build & test (.NET)
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -262,7 +262,7 @@ jobs:
name: EF migration integrity (SQLite + MySql)
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -409,7 +409,7 @@ jobs:
echo "::endgroup::"
functional-e2e:
name: Functional E2E (curl contracts)
name: Functional E2E (curl + UI contracts)
runs-on: ubuntu-latest
# Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E
# flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
@@ -421,7 +421,7 @@ jobs:
# v* tag builds.
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -498,6 +498,27 @@ jobs:
trap 'kill "$PID" 2>/dev/null || true' EXIT
scripts/e2e-functional.sh "http://localhost:${ETV_UI_PORT}" "$CFG"
# ersatztv#445: the UI-interactive flows the curl harness structurally CANNOT express —
# client-side form validation, AuthGate's rendered states, the session cookie authenticating the
# SPA's own /api XHRs, and sign-out through the UserMenu.
#
# Why in THIS job rather than its own: the dominant cost here is `npm ci` + the Release build,
# which are already done above. A separate job would duplicate both to add ~5s of browser work.
# The browser itself is baked into the toolchain image (docker/ci/Dockerfile —
# chromium-headless-shell), so this step installs nothing.
#
# It boots its OWN fresh instance on a DIFFERENT port: the first spec asserts the one-shot Setup
# gate, which the curl harness's auth section has already claimed on its own config dir, and a
# separate port keeps this independent of the previous step's teardown timing.
- name: Run UI-E2E Playwright flows (headless)
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
set -euo pipefail
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8410
# e2e-ui.sh owns the whole lifecycle: fresh config dir, boot, run specs, always kill the
# server. Its exit status is Playwright's.
scripts/e2e-ui.sh
build:
name: Build & push image (amd64)
# Moved back off `small` (server-management#639). This is the one HEAVY job that
@@ -675,7 +696,7 @@ jobs:
# 48 GiB at capacity 4 + a bumblebee overflow slot), which fixes the queue at the source.
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -770,7 +791,7 @@ jobs:
# move to a lighter lane is a server-management capacity call (#604).
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
+4
View File
@@ -73,6 +73,10 @@ web/node_modules
# E2E / screenshot scratch (from Playwright/live-E2E runs) — never committed
/*.png
.playwright-mcp/
# UI-E2E run artifacts: traces/screenshots Playwright writes on failure (outputDir in
# web/playwright.config.ts), plus the report dir it would use if a reporter is ever added (#445).
web/e2e/.output/
web/playwright-report/
# Per-session worktree-ownership marker (H7, ersatztv#303) — local, never committed
.claude-worktree-owner
+45
View File
@@ -16,6 +16,14 @@
# Keep the pins below in sync with what CI actually needs; Renovate tracks the image pins
# (see renovate.json). The two dotnet tool versions mirror what docker-build.yml used to
# `dotnet tool install` per run — bump them here, not in the workflow.
#
# ⚠️ If a branch carrying a change to THIS file gets rebased, `ci-image-pin` goes red: the pin must
# equal the short sha of the commit that touched docker/ci, and a rebase rewrites it. Re-dispatching
# ci-image.yml does not help — it tags the branch HEAD, not this commit. It cost ersatztv#445 two
# recovery cycles because main moved twice mid-review. The pin is also length-checked (exactly 7
# chars, ersatztv#594) so a locally-computed 8-char `--short` now fails loudly at the gate instead of
# at image-pull time. Recovery, and the reason to land a toolchain change on its OWN branch BEFORE
# the work that consumes it: docs/ci-cd.md -> "CI toolchain image".
# Keep the ffmpeg tag on the FROM below equal to the one docker/Dockerfile pins, so CI's ffmpeg
# stays prod-identical. Renovate manages both pins (dockerfile manager); mirror any bump here.
@@ -75,6 +83,39 @@ ENV DOTNET_ROOT=/usr/share/dotnet \
DOTNET_NOLOGO=1 \
DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1
# Headless Chromium for the UI-E2E Playwright flows (ersatztv#445), baked so the `functional-e2e`
# job installs no browser at run time — the same "jobs install nothing" rule as the rest of this
# image. `--with-deps` also pulls the system libraries headless Chromium needs, which the ffmpeg
# runtime base does not ship.
#
# PLAYWRIGHT_VERSION **must equal web/package.json's `@playwright/test` pin** (which is deliberately
# EXACT, no caret). Playwright ties a browser REVISION to the package version and resolves a
# revision-specific path, so a mismatch means "Executable doesn't exist at ...". `scripts/e2e-ui.sh`
# fails early with that instruction rather than a raw Playwright error. Renovate bumps the npm pin;
# when it does, bump this ARG, let ci-image.yml publish the new :<sha>, then update the five
# container pins in docker-build.yml (docs/ci-cd.md -> "CI toolchain image").
ARG PLAYWRIGHT_VERSION=1.62.0
# A fixed, world-readable location instead of root's HOME cache: the path is then stable for
# scripts/e2e-ui.sh to detect, and independent of which user a step runs as.
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
# `chromium-headless-shell`, NOT `chromium` — measured on this exact base: the headless shell is
# 267M where full chromium is 656M, and `chromium.launch()` (headless is the default, and
# web/playwright.config.ts never asks for headed) resolves to the shell anyway. The tradeoff: a
# HEADED run inside this image would fail. That is fine for CI and intentional; a developer running
# headed locally uses their own `npx playwright install chromium`, not this image.
#
# `--with-deps` also installs the system libraries headless Chromium needs, which the ffmpeg runtime
# base does not ship. Verified on the real base (Ubuntu 24.04): Chromium launches as root in a
# container with NO `--no-sandbox`/`chromiumSandbox:false` opt-out, so the config needs no workaround.
RUN npm install -g "playwright@${PLAYWRIGHT_VERSION}" && \
playwright install --with-deps chromium-headless-shell && \
chmod -R a+rX /ms-playwright && \
npm cache clean --force && \
apt-get clean -y && \
rm -rf /var/lib/apt/lists/*
# Global dotnet tools the jobs used to install per run (5s + ~4s each, every run, forever).
# dotnet-ef — the migrations job (was: `dotnet tool install --global dotnet-ef --version 9.0.12`)
# dotnet-reportgenerator-globaltool — the test job's coverage summary
@@ -94,6 +135,10 @@ RUN set -eux; \
python3 --version; \
ffmpeg -version; \
ffprobe -version; \
# Headless Chromium must not merely be present — it must LAUNCH. The interesting failure mode is
# a missing system library or a sandbox refusal at startup, neither of which a file test would
# catch. Fail the IMAGE build here rather than a CI run.
NODE_PATH="$(npm root -g)" node -e "require('playwright').chromium.launch().then(async b => { console.log('chromium ' + b.version()); await b.close(); })"; \
# reportgenerator has no clean version probe: `--version` prints the banner and then exits 1
# ("No report files specified"), so asserting on it would fail this build. Probe that the
# dotnet-tool shim resolves on PATH instead.
+1
View File
@@ -25,6 +25,7 @@ doc below, or that changes which sections a task signal points to.**
| Auth / security-surface work | `docs/decisions/api-auth-security.md` |
| CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` |
| Live local run / Playwright-MCP verification | `docs/e2e-local.md` + `scripts/e2e-local.sh` |
| Adding/changing a UI-E2E browser flow | `docs/e2e-local.md` → "UI-E2E harness" + `scripts/e2e-ui.sh` |
| What does a test suite cover | `docs/testing.md` |
| Legacy Blazor route lookup | `docs/blazor-route-parity.md` (historical #91 phase (b) inventory) |
| "Why do we do X this way" / challenging a convention | **Catalog-first**: `docs/decisions/README.md` (active rows) → `docs/decisions.md` + `docs/decisions/*.md` for full rationale. `docs/decisions/archive/` only for "what did the rule used to be." |
+62 -7
View File
@@ -392,7 +392,7 @@ the image build.
Boots the app **from source** and drives the manual live-E2E curl flows sessions have historically
re-run by hand, turning them into a CI regression net (ersatztv#299). It is the automatable half of
`docs/e2e-local.md`; the two scripts it chains run identically locally and in CI:
`docs/e2e-local.md`; every script it chains runs identically locally and in CI:
1. `npm ci` + `npm run build` (SPA), `dotnet build ErsatzTV.sln -c Release`. (ffmpeg comes from the
CI toolchain image; the old `apt-get install ffmpeg` step is gone — see below.)
@@ -406,6 +406,8 @@ re-run by hand, turning them into a CI regression net (ersatztv#299). It is the
contract** (404 unknown / 202 queued / `scan-status` 200), and the **If-Match/412** round-trip on
`rerun-collections`. Since ersatztv#363 (extended by #444) it also asserts three **lock-contention
409s** that aren't curl-only — see below. A `trap` kills the instance on step exit.
4. `ETV_UI_PORT=8410 scripts/e2e-ui.sh` — boots a second, **fresh** instance and runs the headless
Playwright UI flows (ersatztv#445). See "UI-E2E step" below.
**Lock-contention 409s (ersatztv#363).** The harness now seeds rows the API can't create — a
`LibraryPath` and a Jellyfin media-source — directly into the running instance's SQLite DB (via
@@ -435,9 +437,28 @@ unit-test gate"): it is **not** a `needs:` of `build` and **not (yet) a required
blocks nothing. Promote it to a required check / `build` dependency once it's proven reliable — the
same staged rollout the `migrations` job used. SQLite is the default provider, so unlike `migrations`
it needs **no** DB service container. Runs on PRs and on `main` (regression net); skipped for `v*` tag
builds. Still out of scope (tracked as an ersatztv#363 follow-up): the genuinely **UI-interactive
Playwright** flows (a separate CI browser-tooling lift). The playout-build lock 409 + `isLocked`
projection (#215) landed in #444.
builds. The playout-build lock 409 + `isLocked` projection (#215) landed in #444.
**UI-E2E step (ersatztv#445).** The last step of this same job (item 4 in the summary above; the
`Run UI-E2E Playwright flows (headless)` entry in the YAML) runs the headless-browser flows the curl
harness structurally cannot express — client-side form validation, `AuthGate`'s rendered states, the
session cookie authenticating the **SPA's own** `/api` XHRs, and sign-out via the `UserMenu`:
```
ETV_BUILD_CONFIG=Release ETV_UI_PORT=8410 scripts/e2e-ui.sh
```
`e2e-ui.sh` owns the lifecycle (fresh config dir → boot → `playwright test` → always kill the server)
and exits with Playwright's status. Three deliberate choices:
- **This job, not a new one.** The dominant cost here is `npm ci` + the Release build, both already
done; a separate job would duplicate them to add ~5s of browser work. The browser is baked into the
toolchain image, so the step installs nothing.
- **Its own fresh instance on port 8410.** The first spec asserts the one-shot **Setup** gate, which
step 3's auth section has already claimed on its config dir; a separate port also keeps this step
independent of step 3's teardown timing.
- **`retries: 0`, `serial`, single worker.** #445 asked for deterministic flows and a retry would let a
flaky flow merge looking green (measured: 4 consecutive clean runs, ~2s each). Full rationale and the
rule for extending the suite: `docs/e2e-local.md` → "UI-E2E harness".
### Docs-only skip (ersatztv#416)
@@ -611,10 +632,29 @@ run **inside** a shared image via `container:` instead of installing their toolc
and **no** `dotnet tool install`.
**What it ships:** .NET 10 SDK, Node 22, prod-identical ffmpeg/ffprobe, `git`/`python3`/`jq`/`zstd`,
and the `dotnet-ef` + `dotnet-reportgenerator-globaltool` global tools (which the `migrations` and
the `dotnet-ef` + `dotnet-reportgenerator-globaltool` global tools (which the `migrations` and
`test` jobs used to install on every run — **bump those versions in the Dockerfile, not the
workflow**). Project dependencies (NuGet/npm) are deliberately **not** baked in — they change per
commit and stay on `actions/cache` (`~/.nuget/packages`, `~/.npm`).
workflow**), and **headless Chromium** for the UI-E2E flows (below). Project dependencies (NuGet/npm)
are deliberately **not** baked in — they change per commit and stay on `actions/cache`
(`~/.nuget/packages`, `~/.npm`).
**Headless Chromium for UI-E2E (ersatztv#445).** `PLAYWRIGHT_BROWSERS_PATH=/ms-playwright` holds
`chromium-headless-shell`, installed with `--with-deps` at image build time so the `functional-e2e`
job installs no browser per run. Measured on this exact base: the headless shell is **267M** where
full `chromium` is **656M**, and `chromium.launch()` resolves to the shell anyway because
`web/playwright.config.ts` never asks for headed — the accepted tradeoff being that a *headed* run
inside this image would fail. Also verified on the real base rather than assumed: Chromium launches
as **root inside a container with no `--no-sandbox`/`chromiumSandbox:false` opt-out**, so the config
carries no sandbox workaround. The Dockerfile's build-time smoke test actually *launches* the browser,
so a missing system library fails the image build rather than a CI run.
⚠️ **`ARG PLAYWRIGHT_VERSION` must equal `web/package.json`'s `@playwright/test` pin**, which is
deliberately **EXACT** (no caret): Playwright ties a browser *revision* to the package version, so a
mismatch leaves no usable browser. Renovate bumps the npm pin but cannot know about this ARG — when it
does, bump the ARG, let `ci-image.yml` publish the new `:<sha>`, then update all five container pins.
`scripts/e2e-ui.sh` guards the drift by *launching* a browser up front and failing with exactly that
instruction (it probes by launch, not by path, because `chromium.executablePath()` reports the
full-chromium path that a headless-shell-only image deliberately lacks).
**How it's layered:** `FROM ersatztv-ffmpeg:8.1.2` + `COPY --from=mcr.microsoft.com/dotnet/sdk:10.0-noble-amd64
/usr/share/dotnet` — the same pattern `docker/Dockerfile` uses for the prod image. Our ffmpeg base is
@@ -676,6 +716,21 @@ registry. Renovate tracks the Dockerfile's image pins (`dockerfile` manager, see
(`entrypoint=["/bin/sleep" "10800"]`), so this is belt-and-braces for anyone running the image by
hand — unlike the two above, which are real.
⚠️ **A REBASE invalidates the pin.** The pin must equal the short sha of the commit that touched
`docker/ci/**`, and a rebase **rewrites that commit's sha** — so `ci-image-pin` goes red on a branch
that was green before, with a pin that still resolves to a real (now-orphaned) commit and an image
that still exists in the registry. Worse, the force-push usually does **not** rebuild: `ci-image.yml`
filters on `paths: docker/ci/**`, and a rebase that doesn't change the Dockerfile's *content*
produces no diff for that path, so nothing republishes. And you cannot simply re-dispatch it —
`ci-image.yml` tags `git rev-parse --short HEAD`, i.e. whatever the branch HEAD is when it runs, not
the commit that touched `docker/ci`. Those two coincide only when the `docker/ci` commit *is* HEAD.
Recovery (ersatztv#445 hit this): make the `docker/ci` commit be HEAD again — push a commit that
really does change `docker/ci/**`, let `ci-image.yml` publish `:<its short sha>`, then bump the pin in
a follow-up commit. That is the same two-step below, just re-run after the rebase. The cheapest way to
avoid it entirely is to **land a toolchain-image change on its own, before** the work that consumes
it, so the consuming branch never carries the `docker/ci` commit through a rebase.
**Bumping the pin is enforced, not remembered.** The `ci-image-pin` job (blocking, PR-only; defined
in `pr-checks.yml`, but it greps `docker-build.yml` where the pins live) fails if
`docker-build.yml`'s pin isn't the short sha of the last commit to touch `docker/ci/**` or
+80 -3
View File
@@ -117,28 +117,56 @@ in-file entries.
- [2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented](#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented)
- [2026-07-17 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384)](#2026-07-17--auto-tune-detailpanel-member-list--live-search-index-roll-up-not-ef-enumeration-384)
- [2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390's small-lane move reversed (#406)](#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406)
- [2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)](#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72)
- [2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)](#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70)
- [2026-07-17 — Docs-only CI skip gates STEPS in always-running required jobs, never `if:`-skips them (#416)](#2026-07-17--docs-only-ci-skip-gates-steps-in-always-running-required-jobs-never-if-skips-them-416)
- [2026-07-17 — Docs-only detect must be shallow-checkout safe: FETCH_HEAD + two-dot, not origin/main + three-dot (#416 follow-up)](#2026-07-17--docs-only-detect-must-be-shallow-checkout-safe-fetch_head--two-dot-not-originmain--three-dot-416-follow-up)
- [2026-07-17 — Pre-push guard: don't push a file whose working-tree copy is uncommitted (H13, #416 session)](#2026-07-17--pre-push-guard-dont-push-a-file-whose-working-tree-copy-is-uncommitted-h13-416-session)
- [2026-07-17 — Auto-Tune per-channel overrides reuse the Channel Builder advanced-options DTO; weights + bug-colour logo split out to #425 (#385)](#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385)
- [2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)](#2026-07-17--health-check-remediation-is-server-declared-kind-target-on-an-additive-dto-the-spa-acts-on-it-164)
- [2026-07-18 — Auto-Tune DetailPanel SPA: reusable `SlideOver` + shared advanced-options model; decorative panes dropped to match the backend (#386)](#2026-07-18--auto-tune-detailpanel-spa-reusable-slideover--shared-advanced-options-model-decorative-panes-dropped-to-match-the-backend-386)
- [2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)](#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425)
- [2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)](#2026-07-18--search-all-items-is-paged-to-cap-dos-exposure-spa-add-all-pages-to-completeness-293)
- [2026-07-18 — Collapsible sidebar + nav-group accordions: two `ctv-sidebar-*` localStorage keys, labeled groups default-collapsed (#396)](#2026-07-18--collapsible-sidebar--nav-group-accordions-two-ctv-sidebar--localstorage-keys-labeled-groups-default-collapsed-396)
- [2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)](#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403)
- [2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip](#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip)
- [2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)](#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409)
- [2026-07-19 — WeightedShuffle SPA: weights edited on the multi-collection, order offered only on classic MultiCollection schedule items; fair-share is a reset not a mode (#404)](#2026-07-19--weightedshuffle-spa-weights-edited-on-the-multi-collection-order-offered-only-on-classic-multicollection-schedule-items-fair-share-is-a-reset-not-a-mode-404)
- [2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431)](#2026-07-19--health-check-results-are-ttl-cached-refreshtrue-forces-a-fresh-run-431)
- [2026-07-19 — The `format` gate runs `dotnet format whitespace . --folder`, not the full solution format (#469)](#2026-07-19--the-format-gate-runs-dotnet-format-whitespace----folder-not-the-full-solution-format-469)
- [2026-07-19 — CI `test` job reports a sampled true peak-anon, not cache-inflated `memory.peak` (#412)](#2026-07-19--ci-test-job-reports-a-sampled-true-peak-anon-not-cache-inflated-memorypeak-412)
- [2026-07-19 — Media-server remote-stream URLs are probed before use: a redirected 404 fails closed, everything else fails open, no toggle (#473)](#2026-07-19--media-server-remote-stream-urls-are-probed-before-use-a-redirected-404-fails-closed-everything-else-fails-open-no-toggle-473)
- [2026-07-19 — A media-server library sweep refuses to flag when a successful fetch returns zero items, rather than nuking the whole library (#477)](#2026-07-19--a-media-server-library-sweep-refuses-to-flag-when-a-successful-fetch-returns-zero-items-rather-than-nuking-the-whole-library-477)
- [2026-07-20 — External-JSON playout channels now probe the remote-stream URL too, closing the #473 scope gap (#480)](#2026-07-20--external-json-playout-channels-now-probe-the-remote-stream-url-too-closing-the-473-scope-gap-480)
- [2026-07-20 — `ILibraryRepository.GetOrAddFolder` resolves the folder from the DB, not the caller's `LibraryPath.LibraryFolders` navigation (#488)](#2026-07-20--ilibraryrepositorygetoraddfolder-resolves-the-folder-from-the-db-not-the-callers-librarypathlibraryfolders-navigation-488)
- [2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)](#2026-07-20--jellyfinmusicvideolibraryscanner-reconciles-by-library-scoped-path-diff--hard-delete-not-server-itemid-soft-trash-494)
- [2026-07-20 (#489) — Jellyfin mixed-content libraries map to one library holding many kinds](#2026-07-20-489--jellyfin-mixed-content-libraries-map-to-one-library-holding-many-kinds)
- [2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67)](#2026-07-20--one-logo-drives-the-bug-via-a-shared-channellogo-preset-not-new-schema-67)
- [2026-07-22 — Channel-level graphics-element attachment + seeded On Now/Next text element (#74)](#2026-07-22--channel-level-graphics-element-attachment--seeded-on-nownext-text-element-74)
- [2026-07-20 (#498) — QSV decode is split from QSV encode via a single `QsvPreferNativeDecoder` bool](#2026-07-20-498--qsv-decode-is-split-from-qsv-encode-via-a-single-qsvprefernativedecoder-bool)
- [2026-07-20 — `runs-on: small` means git-only; the two `docker build` jobs move to `ubuntu-latest` (server-management#639)](#2026-07-20--runs-on-small-means-git-only-the-two-docker-build-jobs-move-to-ubuntu-latest-server-management639)
- [2026-07-20 — External-URL channel logos pass through to the graphics engine; never `File.Exists`-gated, never ffmpeg-native (#502)](#2026-07-20--external-url-channel-logos-pass-through-to-the-graphics-engine-never-fileexists-gated-never-ffmpeg-native-502)
- [2026-07-20 — HLS cold start is fixed with `-readrate_initial_burst`, not by raising the work-ahead limit (#350)](#2026-07-20--hls-cold-start-is-fixed-with--readrate_initial_burst-not-by-raising-the-work-ahead-limit-350)
- [2026-07-20 — MCP server (`ErsatzTV.Mcp`) built fresh over frozen `/api/v1`: read + cautious writes (#58)](#2026-07-20--mcp-server-ersatztvmcp-built-fresh-over-frozen-apiv1-read--cautious-writes-58)
- [2026-07-20 — Remote graphics-engine images are fetched through a bounded, pooled `IRemoteImageFetcher`; re-fetched per element init, not cached (#511)](#2026-07-20--remote-graphics-engine-images-are-fetched-through-a-bounded-pooled-iremoteimagefetcher-re-fetched-per-element-init-not-cached-511)
- [2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521)](#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521)
- [2026-07-21 — Parallel orientation + selection is the startup protocol; #237 retired (#520)](#2026-07-21--parallel-orientation--selection-is-the-startup-protocol-237-retired-520)
- [2026-07-21 — External channel-logo URLs are downloaded and cached at save time; the render path never fetches a logo (#525)](#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525)
- [2026-07-21 — QSV hardware-frame headroom is a floor, not an operator preference (#529)](#2026-07-21--qsv-hardware-frame-headroom-is-a-floor-not-an-operator-preference-529)
- [2026-07-21 — Check the worked issue before the decision corpus; a closed tracker's comments need no retrofit (#524)](#2026-07-21--check-the-worked-issue-before-the-decision-corpus-a-closed-trackers-comments-need-no-retrofit-524)
- [2026-07-21 — Work-ahead slots are claimed atomically by the caller, released by the transcode it hands them to (#536)](#2026-07-21--work-ahead-slots-are-claimed-atomically-by-the-caller-released-by-the-transcode-it-hands-them-to-536)
- [2026-07-21 — An on-demand time shift rebuilds the channel's cached XMLTV so the guide can't lag playback (#68)](#2026-07-21--an-on-demand-time-shift-rebuilds-the-channels-cached-xmltv-so-the-guide-cant-lag-playback-68)
- [2026-07-21 — Session end fast-forwards the shared checkout; a stale tree serves stale FILES (#541)](#2026-07-21--session-end-fast-forwards-the-shared-checkout-a-stale-tree-serves-stale-files-541)
- [2026-07-21 — `WorkAheadSlots.Release()` clamps before decrementing and reports unbalance in-band (#539)](#2026-07-21--workaheadslotsrelease-clamps-before-decrementing-and-reports-unbalance-in-band-539)
- [2026-07-21 — `from-lineup` advanced overrides express "clear to none" via a typed `clear` enum list (#135)](#2026-07-21--from-lineup-advanced-overrides-express-clear-to-none-via-a-typed-clear-enum-list-135)
- [2026-07-21 — Browser channel preview is a server-declared per-channel capability (#60)](#2026-07-21--browser-channel-preview-is-a-server-declared-per-channel-capability-60)
- [2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381)](#2026-07-22--sequential-yaml-playout-gets-a-golden-scripted-is-excluded-from-the-golden-net-by-construction-381)
- [2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392)](#2026-07-22--per-schedule-clock-boundary-padding-is-a-synthetic-content-less-pad-over-the-existing-per-episode-machinery-392)
- [2026-07-23 — Channel origin is immutable creation-provenance, stamped at insert, not a health signal (#414)](#2026-07-23--channel-origin-is-immutable-creation-provenance-stamped-at-insert-not-a-health-signal-414)
- [2026-07-23 — Facet-value typeahead is a new endpoint, allow-listed to text fields, no caching (#434)](#2026-07-23--facet-value-typeahead-is-a-new-endpoint-allow-listed-to-text-fields-no-caching-434)
- [2026-07-23 — Relative-date rule builder operators are a frontend-only mapping onto existing Lucene macros (#435)](#2026-07-23--relative-date-rule-builder-operators-are-a-frontend-only-mapping-onto-existing-lucene-macros-435)
- [2026-07-23 — Channel health = a server-derived `health` object on the channel DTOs, built-timeline detection (#415)](#2026-07-23--channel-health--a-server-derived-health-object-on-the-channel-dtos-built-timeline-detection-415)
- [2026-07-25 — Rule-builder group nesting is bounded-arbitrary depth (`MAX_GROUP_DEPTH`), not one level (#436)](#2026-07-25--rule-builder-group-nesting-is-bounded-arbitrary-depth-max_group_depth-not-one-level-436)
- [2026-07-25 — UI-E2E: headless Playwright flows in the existing `functional-e2e` job, browser baked into the CI image (#445)](#2026-07-25--ui-e2e-headless-playwright-flows-in-the-existing-functional-e2e-job-browser-baked-into-the-ci-image-445)
---
@@ -1148,7 +1176,7 @@ non-existence-check, and the missing `Name=` on `PlayoutController` Create/Delet
## 2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)
`key: ci.functional-e2e-harness` · `status: active` · `since: 2026-07-16` · `supersedes: none` · `superseded-by: none`
**Rule:** The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs curl-only, deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check.
**Rule:** The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see `ci.ui-e2e-harness`.
**Signals:** staged rollout precedent (`migrations` job), racy/interactive flows deferred · paths: `scripts/e2e-local.sh`, `scripts/e2e-functional.sh` · issues: #299
**Mechanics:** `scripts/e2e-functional.sh`
@@ -1176,7 +1204,8 @@ auth/CSRF/security-stamp flow, the library-scan status contract (404/202/`scan-s
empty local library still enqueues `202`; an empty collection drives the concurrency editor). The 409
"already-scanning" re-trigger (needs a long-running scan to be non-racy), the playout-build lock 409,
and the genuinely UI-interactive Playwright flows are deferred as #299 follow-ups rather than shipped
flaky. Assertions were written against a real running instance, not the source — which caught that
flaky. (All three have since landed: the two 409s in #363/#444, the Playwright flows in #445 —
`ci.ui-e2e-harness` — as a second step of this same job.) Assertions were written against a real running instance, not the source — which caught that
`/artwork/*` returns `400` (not the `404` a static read suggested); extend the harness the same way.
## 2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)
@@ -3637,3 +3666,51 @@ so a regression is diagnosable without decoding a random seed.
**No backend change.** Nesting depth is entirely a client-side model concern; the stored query string
is still plain Lucene, so nothing about `/api/v1`, the search index or the MCP surface moves.
## 2026-07-25 — UI-E2E: headless Playwright flows in the existing `functional-e2e` job, browser baked into the CI image (#445)
`key: ci.ui-e2e-harness` · `status: active` · `since: 2026-07-25` · `supersedes: none` · `superseded-by: none`
**Rule:** The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`'s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach.
**Signals:** UI-E2E, Playwright headless, boot-gate/Setup/Login flows, chromium-headless-shell, PLAYWRIGHT_BROWSERS_PATH=/ms-playwright, browser baked not installed, e2e-ui.sh, vitest `e2e/**` exclude, port 8410, no retries · paths: `scripts/e2e-ui.sh`, `web/playwright.config.ts`, `web/e2e/boot-gate.spec.ts`, `docker/ci/Dockerfile`, `.gitea/workflows/docker-build.yml`, `docs/e2e-local.md` · issues: #445, #363, #299
**Mechanics:** `scripts/e2e-ui.sh`; `docs/e2e-local.md` -> "UI-E2E harness"; `docs/ci-cd.md` -> "CI toolchain image"
Completes the last #299/#363 follow-up — the UI-interactive flows both prior records deferred.
**Only assert what curl structurally cannot.** The curl harness already covers the auth *HTTP* contracts
(setup-claim 200/409, login 401/200, CSRF 403, stamp rotation); re-asserting them through a browser buys
nothing but flake surface. Scope is the four things curl cannot express: client-side form validation
(the Setup confirm-password gate is pure React state, makes no request), `AuthGate`'s *rendered* states,
the session cookie authenticating the **SPA's own** `/api` XHRs (curl proves the cookie works for curl,
not that the app sends it), and sign-out via `UserMenu`. **This scoping rule is the durable part** —
extend the browser suite only when a contract fails that test.
**Baked browser, not a per-run install.** `chromium-headless-shell` lands in `/ms-playwright` at image
build time, so the job installs nothing — the "jobs install nothing at run time" rule of the shared CI
toolchain image (#390, which has no standalone record — see `ci.runner-placement` and `docs/ci-cd.md`).
Measured on the real base: headless shell **267M** vs full `chromium` **656M** (+171M compressed pull),
and `chromium.launch()` resolves to the shell anyway; the tradeoff is that a *headed* run in the image
fails. Verified rather than assumed: Chromium runs as root in-container with **no** `--no-sandbox`
opt-out. The browser revision is tied to the npm package version, so that pin is EXACT and `e2e-ui.sh`
guards drift by **launching** a browser — not by path, since `executablePath()` reports the
full-chromium path such an image lacks.
**Why that job and not a new one** (`docs/ci-cd.md` has the detail): its `npm ci` + Release build are
already done, so a separate job would duplicate the dominant cost to add ~5s of browser work. The UI step
boots its **own** fresh instance on port 8410 — the first spec asserts the one-shot Setup gate the curl
step has already claimed.
**`serial` + `retries: 0`, and vitest must not collect these files.** Server state is shared and partly
one-shot (the setup-claim), so specs are serial/single-worker; each `test` still gets its own browser
context, which gives the login specs a signed-out browser without a logout dance. Retries are 0 even in
CI — a retry lets a flaky flow merge looking green. Coupling worth knowing: vitest's default `include`
glob would run `web/e2e/*.spec.ts` under jsdom, so `vite.config.ts` excludes `e2e/**` by spreading
`configDefaults.exclude` — not by narrowing `include` to `src/**`, which would silently stop collecting
the real vitest test under `web/scripts/`.
**Lifecycle correctness in `scripts/e2e-ui.sh`** — all four found by adversarial review, **none by a
passing run**; that is the transferable lesson (green runs never exercise the failure/interrupt paths).
(a) Install the cleanup trap *before* boot and have `e2e-local.sh` publish its PID to an opt-in
`ETV_PIDFILE` as it forks, so a mid-boot signal can still reap the server — a "kill whatever LISTENS on
the port" fallback is wrong twice over: it needs `lsof` (absent from the CI image) and it *infers*
ownership instead of proving it. (b) Re-raise signals, so a cancelled run cannot exit 0. (c) Never
`if ! cmd; then status=$?` — under `!` bash sets `$?` to the logical negation, so it reads 0 and a
FAILING run exits 0, silently passing CI. (d) `exec` the app inside the backgrounded subshell, or `$!`
is the SUBSHELL on bash 3.2 (stock macOS) and every PID-based kill targets the wrong process.
+3 -2
View File
@@ -40,7 +40,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `ci.docs-only-detect-shallow-safe` | The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check. | 2026-07-17 | [link](../decisions.md#2026-07-17--docs-only-detect-must-be-shallow-checkout-safe-fetch_head--two-dot-not-originmain--three-dot-416-follow-up) |
| `ci.docs-only-skip-steps` | A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != 'true'`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt. | 2026-07-17 | [link](../decisions.md#2026-07-17--docs-only-ci-skip-gates-steps-in-always-running-required-jobs-never-if-skips-them-416) |
| `ci.format-gate-folder-mode` | The blocking `format` CI job (and matching pre-commit hook) runs `dotnet format whitespace . --folder --include <files>` instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage. | 2026-07-19 | [link](../decisions.md#2026-07-19--the-format-gate-runs-dotnet-format-whitespace----folder-not-the-full-solution-format-469) |
| `ci.functional-e2e-harness` | The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs curl-only, deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. | 2026-07-16 | [link](../decisions.md#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299) |
| `ci.functional-e2e-harness` | The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see `ci.ui-e2e-harness`. | 2026-07-16 | [link](../decisions.md#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299) |
| `ci.gitea-milestone-filter-noop` | Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`. | 2026-07-21 | [link](workflow-process.md#2026-07-21--giteas-milestones-issue-filter-silently-no-ops-on-names-containing--or--542) |
| `ci.infra-shaped-red-under-load` | When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure. | 2026-07-21 | [link](workflow-process.md#2026-07-21--an-infra-shaped-red-under-host-load-is-not-a-code-failure-542) |
| `ci.killed-job-triage` | Never trust a job's `conclusion` field alone — read the log tail and require an `❌ Failure - Main …` marker before treating a red as a real failure. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-killed-ci-job-reports-conclusion-failure-read-the-log-tail-before-diagnosing-the-diff-542) |
@@ -50,6 +50,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--root-screenshot-guard-pre-commit-refuses-root-level-png-303-h3) |
| `ci.runner-placement` | No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's). | 2026-07-17 | [link](../decisions.md#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406) |
| `ci.small-lane-git-only` | `runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap. | 2026-07-20 | [link](../decisions.md#2026-07-20--runs-on-small-means-git-only-the-two-docker-build-jobs-move-to-ubuntu-latest-server-management639) |
| `ci.ui-e2e-harness` | The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`'s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach. | 2026-07-25 | [link](../decisions.md#2026-07-25--ui-e2e-headless-playwright-flows-in-the-existing-functional-e2e-job-browser-baked-into-the-ci-image-445) |
| `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](workflow-process.md#2026-07-21--build-and-verify-locally-then-trust-it-ci-confirms-542) |
| `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](workflow-process.md#2026-07-21--heavy-render-web-tests-need-explicit-per-test-vitest-timeouts-on-the-ci-vm-542) |
| `concurrency.diff-scalar-fanout` | The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned `PreconditionFailedError` out of any handler's generic `catch(Exception)`→422 mapping, and preserving each aggregate's existing `SaveChangesAsync() > 0` gate semantics under the new unconditional `Version++`. | 2026-07-11 | [link](optimistic-concurrency.md#2026-07-11--253-pr3-diff--scalar-concurrency-fan-out-collection--playout2--multicollection--reruncollection) |
@@ -162,7 +163,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `spa.yaml-validator-textarea` | The YAML playout validator takes pasted YAML via a `<textarea>`, not a server-side file path, since the SPA has no filesystem access. | 2026-07-09 | [link](../decisions.md#2026-07-09--yaml-playout-validator-paste-textarea-instead-of-a-server-file-path) |
| `startup.parallel-orientation` | A fresh session runs two concurrent tracks at startup — Orientation (`AGENTS.md`/`CLAUDE.md` → `docs/README.md` task-signal map → the active decisions catalog `docs/decisions/README.md`) and, only when no issue is named, Selection (`scripts/select-queue.sh N`, deterministic live-Gitea ranking). A named issue skips Selection entirely. ersatztv#237, the closed pickup tracker this replaces, is reduced to a single archival breadcrumb and MUST NOT be read for live state. | 2026-07-21 | [link](../decisions.md#2026-07-21--parallel-orientation--selection-is-the-startup-protocol-237-retired-520) |
| `testing.e2e-cleanup-scope-by-pid` | An E2E harness or agent may only kill processes whose PIDs it captured at launch — capture the PID; whoever owns the lifecycle releases it from a `trap ... EXIT INT TERM`. Never `pkill -f "dotnet ErsatzTV.dll"` (or any pattern that can match a process this run did not start). A foreign listener is reported, not reaped. | 2026-07-25 | [link](workflow-process.md#2026-07-25--e2e-cleanup-kills-only-the-pids-it-started-never-a-pkill--f-pattern-586) |
| `testing.e2e-local-fresh-config-dir` | Always point `scripts/e2e-local.sh` at a fresh config dir — a reused one never emits the probe's ready line and the script kills a healthy server. | 2026-07-21 | [link](workflow-process.md#2026-07-21--run-scriptse2e-localsh-against-a-fresh-config-dir-a-reused-one-hangs-the-readiness-probe-542) |
| `testing.e2e-local-fresh-config-dir` | Always point `scripts/e2e-local.sh` at a fresh config dir — leftover channels/schedules/DB rows bleed state between runs and corrupt assertions. (The *readiness-probe hang* this record was originally written about was fixed in #533; the fresh-dir rule stands on state-bleed grounds alone.) | 2026-07-21 | [link](workflow-process.md#2026-07-21--run-scriptse2e-localsh-against-a-fresh-config-dir-a-reused-one-hangs-the-readiness-probe-542) |
| `testing.live-e2e-prepush-timing` | Run live-E2E via `scripts/e2e-local.sh` before pushing a write-path or UI change, and exercise download endpoints with curl, never a browser tab. | 2026-07-21 | [link](workflow-process.md#2026-07-21--live-e2e-runs-before-the-push-and-downloads-are-curled-not-browsed-542) |
| `testing.playwright-mcp-download-and-recovery` | In Playwright-MCP E2E, fetch file-download endpoints with curl — never a browser tab or `window.open` — and if browser tools stall repeatedly, `pkill -f ms-playwright-mcp` and drive a fresh session. | 2026-07-21 | [link](workflow-process.md#2026-07-21--playwright-mcp-curl-download-endpoints-never-open-a-tab-or-windowopen-542) |
| `testing.scripted-playout-golden-deferred` | The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted's *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can't pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it. | 2026-07-22 | [link](../decisions.md#2026-07-22--sequential-yaml-playout-gets-a-golden-scripted-is-excluded-from-the-golden-net-by-construction-381) |
+19 -10
View File
@@ -432,18 +432,27 @@ dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@
## 2026-07-21 — Run `scripts/e2e-local.sh` against a FRESH config dir; a reused one hangs the readiness probe (#542)
`key: testing.e2e-local-fresh-config-dir` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Always point `scripts/e2e-local.sh` at a fresh config dir — a reused one never emits the probe's ready line and the script kills a healthy server.
**Signals:** `READY_LINE` · `Done migrating search index` · `Search index is already version 50` · readiness probe timeout · e2e-local hang · paths: `scripts/e2e-local.sh`, `docs/e2e-local.md` · issues: #542, #533, #502, #524
**Mechanics:** The script greps one fixed `READY_LINE` — there is no "wait for either line" option today.
**Rule:** Always point `scripts/e2e-local.sh` at a fresh config dir — leftover channels/schedules/DB rows bleed state between runs and corrupt assertions. (The *readiness-probe hang* this record was originally written about was fixed in #533; the fresh-dir rule stands on state-bleed grounds alone.)
**Signals:** fresh config dir · state bleed between e2e runs · `READY_REGEX` · `Done migrating search index` · `Search index is already version` · readiness probe · e2e-local hang (historical, fixed #533) · paths: `scripts/e2e-local.sh`, `docs/e2e-local.md` · issues: #542, #533, #502, #524
**Mechanics:** Since #533 the script greps a `READY_REGEX` **alternation** over both ready lines, so a reused dir no longer hangs. `scripts/e2e-ui.sh` additionally *requires* a fresh dir, because its first spec asserts the one-shot Setup gate.
The probe waits for `Done migrating search index`, which only a **fresh** config emits. A reused config
logs `Search index is already version 50` instead, so the line never arrives, the probe times out, and
the script kills a perfectly healthy server — a failure that reads as a broken build.
⚠️ **The heading is historical** — as of #533 a reused dir no longer hangs the probe; read the Rule,
not the heading. **Amended rather than superseded**, for two reasons: the operative Rule never reversed
(only its rationale moved from "the probe hangs" to "state bleed"), and this `key:` is cited from
`docs/handoffs/chicorytv-issue-queue.md` and two `docs/superpowers/` files — superseding would aim those
at an archived, stale-labelled record instead of live guidance. (Note the heading being unrenameable is
*not* an argument for amending: a supersession relocates to `archive/` with its heading intact — see
`archive/api.md`. The two are independent.)
Surfaced in the #502 session (two failed launches before the cause was obvious) and recovered from the
old tracker's comment log by the #524 triage. `docs/e2e-local.md` documents the ready line and
recommends a fresh dir, but not this failure mode. Widening the probe to
`Done migrating search index|Search index is already version` is tracked as **#533**.
Originally: the probe waited for `Done migrating search index`, which only a **fresh** config emits; a
reused one logs `Search index is already version {Version}`, so the line never arrived, the probe timed
out after 120s, and the script killed a healthy server — reading as a broken build. Surfaced in #502 and
recovered from the old tracker by the #524 triage.
**#533 fixed the mechanism, not the guidance.** The probe now matches either line (the handler's
`if`/`else` is exhaustive). The original advice survives for a *different* reason — a reused dir still
bleeds state — and `scripts/e2e-ui.sh` (#445, `ci.ui-e2e-harness`) hard-requires a fresh one, since a
claimed local admin makes its Setup-gate spec unreachable.
## 2026-07-21 — Channel branding is NOT testable through the troubleshooting-playback API (#542)
+81 -7
View File
@@ -76,6 +76,20 @@ skip rubric uses (kickoff workflow lore).
may 404/error. The UI and the entire `/api/*` surface are served on the **same port**, 8409 by
default (override with `ETV_UI_PORT`).
**On a reused config dir that line never appears.** `RebuildSearchIndexHandler` logs one of two
mutually-exclusive lines immediately before `SystemStartup.SearchIndexIsReady()`, and which one
depends on whether the index had anything to migrate:
| Config dir | Line logged |
| --- | --- |
| fresh | `Done migrating search index in {Duration}` |
| reused (index already current) | `Search index is already version {Version}` |
So when waiting on readiness, match **either** line — the handler's `if`/`else` is exhaustive, so
the pair covers every path to ready. Watching only for `Done migrating search index` against a
reused dir waits out the full timeout and then kills a perfectly healthy server; that cost one
session two failed launches before the cause was found (ersatztv#533).
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*
@@ -161,10 +175,20 @@ Usage:
```bash
scripts/e2e-local.sh [CONFIG_DIR]
```
- `CONFIG_DIR` defaults to a fresh `mktemp -d` if omitted.
- `CONFIG_DIR` defaults to a fresh `mktemp -d` if omitted. Prefer a fresh dir per run for
**state-bleed** reasons (above); the readiness probe itself handles a reused dir since #533.
- ⚠️ **Never run two instances concurrently, even on different ports.** Each run `rm -rf`s and
re-copies the *same* `ErsatzTV/bin/<config>/net10.0/wwwroot`, so the second run pulls the static
files out from under a still-starting first instance. The symptom — a readiness timeout, or `/app`
404ing on the instance that *did* start — looks nothing like a shared-directory race. Run
sequentially; the CI job's curl and UI-E2E steps do.
- **`ETV_PIDFILE`** (optional): the server PID is written here the instant it forks, *before* the
readiness wait. `scripts/e2e-ui.sh` sets it so it can still reap the server if it is killed
mid-boot, before it has parsed `PID=` from stdout. Unset means unchanged behaviour.
- 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.
- Waits (up to 120s) for readiness — either `Done migrating search index` (fresh config) or
`Search index is already version` (reused config); see the step-4 table above.
- **Pre-flights both bound ports** (`ETV_UI_PORT` and `ETV_STREAMING_PORT`): if something is already
listening, it fails immediately naming the offending PID, rather than letting Kestrel fail its bind
a moment later and surface as `process N exited before becoming ready` plus a log tail — a framing
@@ -250,13 +274,63 @@ What it covers. Most assertions are curl-only (no seeded media / ffmpeg / browse
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-up): the genuinely **UI-interactive Playwright** flows (a
separate CI browser-tooling lift). The playout-build lock 409 + `isLocked` projection (#215) landed in
#444 — see the lock-contention bullet above. 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
The playout-build lock 409 + `isLocked` projection (#215) landed in #444 — see the lock-contention
bullet above. The genuinely **UI-interactive Playwright** flows landed in #445 — see the next section.
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).
## UI-E2E harness: `scripts/e2e-ui.sh` + `web/e2e/*.spec.ts`
The handful of contracts that **cannot** be expressed as curl calls, driven by **headless Playwright**
(never claude-in-chrome, per project convention — ersatztv#445). Unlike `e2e-functional.sh`, this script
owns the whole lifecycle: it boots a fresh instance, runs the specs, and always kills the server (trap
on `EXIT`/`INT`/`TERM`), exiting with Playwright's status:
```bash
scripts/e2e-ui.sh # fresh config dir, all specs
scripts/e2e-ui.sh "$(mktemp -d)" --headed --debug # extra args pass through to `playwright test`
```
**A fresh config dir is required, not merely preferred** — the first spec asserts the one-shot Setup
(first-run admin claim) gate, which only exists while the server has no local admin. Pointing it at a
reused dir fails that spec by design.
**What it covers, and the rule for extending it.** Only assert here what curl *structurally cannot* —
the curl harness above already covers the auth HTTP contracts, so re-asserting them through a browser
buys nothing but flake surface. The four things that qualify:
| Contract | Why curl can't reach it |
| --- | --- |
| Setup card's confirm-password gate | Pure React state; makes no request, so there is no HTTP contract |
| `AuthGate` states (Setup vs Login vs app) | Assertion is about what *renders*, not a status code |
| Session cookie on the SPA's own `/api` XHRs | curl proves the cookie works *for curl*, not that the app sends it |
| Sign-out via the `UserMenu` | A DOM interaction chain, not a single endpoint |
**Determinism rules** (#445 asked for deterministic flows, so these are deliberate):
- `retries: 0`, in CI too — a retry would let a genuinely flaky flow merge looking green.
- `serial` + `workers: 1`: server state is shared and partly one-shot (the setup-claim).
- Each `test` still gets its own browser context (own cookie jar) — that is what gives the login specs a
genuinely signed-out browser without a logout dance. **Anything needing a session held across steps
must stay inside one `test`.**
- Address form fields by their unique **placeholders**, not labels: the shared `<Input>` wraps its
`<input>` in a `<label>`, so a field's accessible name absorbs its error text when invalid
(`"Confirm password Passwords do not match."`).
- A fresh config is **not** empty — `DbInitializer` seeds one default channel (`"ErsatzTV"`, number 1),
so the Channels *empty state* is unreachable there; assert the seeded row instead.
**The browser is baked into the CI toolchain image**, not installed per run (`docker/ci/Dockerfile`:
`chromium-headless-shell`, 267M vs 656M for full chromium). Two consequences:
- `web/package.json`'s `@playwright/test` pin is **EXACT** (no caret) because Playwright ties a browser
revision to the package version. Bumping it requires rebuilding the CI image — `scripts/e2e-ui.sh`
guards this by *launching* a browser up front and failing with that instruction. See `docs/ci-cd.md`
→ "CI toolchain image".
- A **headed** run inside the CI image would fail (headless shell only). Locally, run
`cd web && npx playwright install chromium` once and headed works from your own cache.
Vitest deliberately excludes `e2e/**` (`web/vite.config.ts`) — its default `**/*.spec.*` glob would
otherwise try to run these under jsdom.
## Seeding a local TV library for E2E
Channels/playouts are API-seedable (step 5 above), but a **local media library is not** — there
+9 -2
View File
@@ -12,7 +12,8 @@ before adding tests, not just `docs/contributing.md` §8 (which now just points
| `ErsatzTV.Scanner.Tests` | Library scanning: scan handlers, folder scanners, NFO readers | Handler tests substitute the folder scanners + `ILibraryRepository` and assert the resulting repository writes (e.g. `ScanLocalLibraryHandlerTests` pins which `LastScan` levels a scan records — ersatztv#264). Fakes/`Testably` back the file-system-facing scanners. ~1,485 tests (approximate on purpose — an exact count goes stale on every PR that adds one). Additionally contains `Core/FFmpeg/TranscodingTests` — `[Explicit]` + `[Combinatorial]`, so it never runs in CI or a plain `dotnet test` (it needs real ffmpeg/hardware) and contributes 0 to that count; run it by name when touching the transcoding pipeline. |
| `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. |
| `ErsatzTV.FFmpeg.Tests` | FFmpeg command construction | Build a pipeline, assert the exact rendered arg string (`PipelineBuilderBaseTests.cs`). |
| `web/` (vitest) | React SPA unit tests | 330 tests; run alongside typecheck + build (see below). |
| `web/` (vitest) | React SPA unit tests | 995 tests across 105 files; run alongside typecheck + build (see below). Collects `src/**` *and* `web/scripts/**`, but deliberately **excludes** `web/e2e/**` (the Playwright specs — vitest's default `**/*.spec.*` glob would otherwise run them under jsdom). |
| `web/e2e/` (Playwright) | UI-interactive E2E flows against a **live** instance | Not a unit suite and **not** part of `npm test` — needs a running server, so it runs via `scripts/e2e-ui.sh` (boots its own fresh instance) and in CI as a step of the `functional-e2e` job. Headless Chromium, `serial`, `retries: 0`. Scope rule: assert only what the curl harness structurally cannot. See `docs/e2e-local.md` → "UI-E2E harness". |
## Golden-file nets
@@ -89,12 +90,18 @@ dotnet test ErsatzTV.Core.Tests --filter FullyQualifiedName~ChannelPlaylistGolde
Web (`web/`):
```bash
npm test # vitest
npm test # vitest (excludes web/e2e — those need a live server)
npm run typecheck # tsc -b --pretty false
npm run lint # eslint .
npm run build # tsc -b && vite build
```
UI-E2E (needs a built solution + built SPA; boots and tears down its own instance):
```bash
scripts/e2e-ui.sh # from the repo root, NOT web/
```
## Per-PR verification gate
Before opening a PR: build the solution, run `ErsatzTV.Tests` + `ErsatzTV.Core.Tests` (plus
+40 -5
View File
@@ -9,7 +9,16 @@
# Usage:
# scripts/e2e-local.sh [CONFIG_DIR]
#
# CONFIG_DIR defaults to a fresh `mktemp -d` if omitted. NEVER reuse a config dir across runs.
# DO NOT run two instances of this script concurrently, even on different ports. Every run
# `rm -rf`s and re-copies the SAME build-output wwwroot (below), so a second run yanks the static
# files out from under a server that is still starting — the symptom is a readiness timeout or a
# 404-ing /app on the first instance, which looks nothing like a shared-directory race. Run them
# sequentially (the CI job's curl and UI-E2E steps do exactly that).
#
# CONFIG_DIR defaults to a fresh `mktemp -d` if omitted. Prefer a fresh dir per run: leftover
# channels/schedules/DB rows bleed state between runs and corrupt assertions. Reuse no longer
# *hangs* the readiness probe (ersatztv#533 — see READY_REGEX below), but the state-bleed reason to
# avoid it stands.
#
# On success, prints:
# PID=<pid>
@@ -64,7 +73,14 @@ for port_var in ETV_UI_PORT:"$PORT" ETV_STREAMING_PORT:"$ETV_STREAMING_PORT"; do
;;
esac
done
READY_LINE="Done migrating search index"
# Readiness marker. RebuildSearchIndexHandler logs one of TWO mutually-exclusive lines immediately
# before it calls SystemStartup.SearchIndexIsReady(), and which one depends on the config dir:
# fresh config -> "Done migrating search index in {Duration}" (the index actually migrated)
# reused config -> "Search index is already version {Version}" (nothing to migrate)
# Matching only the first made this probe hang forever on a REUSED config dir and then kill a
# perfectly healthy server (ersatztv#533). The handler's if/else is exhaustive, so this alternation
# covers every path to readiness — do not narrow it back to one line.
READY_REGEX="Done migrating search index|Search index is already version"
TIMEOUT_SECS=120
# Pre-flight (before mktemp, so a refused run leaks no config dir): name the process holding the
@@ -113,15 +129,34 @@ 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)..."
# `exec` matters: without it, whether `$!` is the dotnet process or the wrapping SUBSHELL depends on
# bash's last-command optimization — bash 5.x collapses it to the leaf, but bash 3.2 (stock macOS
# /bin/bash, which `#!/usr/bin/env bash` picks up without Homebrew bash on PATH) reports the
# subshell. Callers then kill the subshell, the real server is orphaned holding the port, and every
# PID-based liveness check reports success. `exec` replaces the subshell with dotnet, so `$!` is the
# server on every bash.
(
cd "$BUILD_DIR"
ETV_CONFIG_FOLDER="$CONFIG_DIR" ETV_UI_PORT="$PORT" ETV_STREAMING_PORT="$ETV_STREAMING_PORT" dotnet ErsatzTV.dll
# All three exported together, then `exec`: ETV_STREAMING_PORT is #586's (the app binds a
# second listener that otherwise defaults to 8409 regardless of ETV_UI_PORT); `exec` is #533's
# (without it `$!` is this SUBSHELL, not dotnet, on bash 3.2 — every PID-based kill then
# targets the wrong process). Both are required; neither supersedes the other.
export ETV_CONFIG_FOLDER="$CONFIG_DIR" ETV_UI_PORT="$PORT" ETV_STREAMING_PORT="$ETV_STREAMING_PORT"
exec dotnet ErsatzTV.dll
) >"$LOG_FILE" 2>&1 &
PID=$!
echo "Waiting up to ${TIMEOUT_SECS}s for '$READY_LINE'..."
# Publish the PID immediately, BEFORE the readiness wait below, for a caller that must be able to
# reap the server even though it never got to parse this script's stdout — e.g. killed while this
# script is still waiting for readiness. That window is exactly where a leak would otherwise happen.
# Opt-in: unset means "behave exactly as before". scripts/e2e-ui.sh sets it before booting.
if [ -n "${ETV_PIDFILE:-}" ]; then
printf '%s\n' "$PID" >"$ETV_PIDFILE"
fi
echo "Waiting up to ${TIMEOUT_SECS}s for readiness ('$READY_REGEX')..."
elapsed=0
until grep -q "$READY_LINE" "$LOG_FILE" 2>/dev/null; do
until grep -Eq "$READY_REGEX" "$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
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env bash
# scripts/e2e-ui.sh — boot a FRESH ErsatzTV instance and run the UI-interactive Playwright flows
# (ersatztv#445). The curl counterpart is scripts/e2e-functional.sh; see docs/e2e-local.md for both.
#
# Usage:
# scripts/e2e-ui.sh [CONFIG_DIR] # extra args after CONFIG_DIR are passed to `playwright test`
#
# CONFIG_DIR defaults to a fresh `mktemp -d`. A FRESH dir is REQUIRED, not merely preferred: the first
# spec asserts the Setup (first-run admin claim) gate, which only appears while the server has no
# local admin. Point this at a reused config dir and that spec fails by design.
#
# Unlike e2e-local.sh, this script OWNS the instance lifecycle: it boots the server, runs the specs,
# and always kills the server on the way out (success, failure, or interrupt). Exit status is
# Playwright's, so this is safe to use directly as a CI gate.
#
# Assumes `dotnet build` + `cd web && npm ci && npm run build` have already run — same contract as
# e2e-local.sh, which this delegates the boot to.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
PORT="${ETV_UI_PORT:-8409}"
CONFIG_DIR="${1:-$(mktemp -d)}"
shift || true
# The Chromium build Playwright drives is baked into the CI toolchain image at this path
# (docker/ci/Dockerfile); locally it lands in the default per-user cache. Only export the shared
# path when it actually exists, so a local run keeps using the user cache.
if [ -z "${PLAYWRIGHT_BROWSERS_PATH:-}" ] && [ -d /ms-playwright ]; then
export PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
fi
if [ ! -d "$REPO_ROOT/web/node_modules/@playwright/test" ]; then
echo "error: web/node_modules/@playwright/test is missing — run 'cd web && npm ci' first" >&2
exit 1
fi
# Version-drift guard: actually LAUNCH the browser and close it. Playwright pins a browser REVISION
# per package version, so bumping web/package.json's @playwright/test pin (e.g. by Renovate) without
# rebuilding the CI toolchain image leaves no usable browser. Failing here with an actionable message
# beats Playwright's bare "Executable doesn't exist at ..." mid-suite.
#
# Probing by LAUNCH rather than by path is deliberate: the CI image bakes only
# `chromium-headless-shell` (267M vs 656M for full chromium), and `chromium.executablePath()` reports
# the FULL chromium path, which does not exist there — a path check would fail on a perfectly good
# image. Launching exercises exactly what the specs do.
PW_VERSION="$(cd "$REPO_ROOT/web" && node -p "require('@playwright/test/package.json').version")"
# mktemp, not a predictable /tmp/...$$ path: on a shared host a pre-created symlink at a guessable
# name would redirect this write.
PW_PROBE_ERR="$(mktemp)"
if ! (cd "$REPO_ROOT/web" && node -e "require('@playwright/test').chromium.launch().then(b=>b.close())") 2>"$PW_PROBE_ERR"; then
echo "error: could not launch headless chromium for @playwright/test ${PW_VERSION}:" >&2
sed 's/^/ /' "$PW_PROBE_ERR" >&2 || true
rm -f "$PW_PROBE_ERR"
echo " In CI this means the browser baked into the toolchain image no longer matches" >&2
echo " web/package.json's @playwright/test pin (${PW_VERSION}): bump PLAYWRIGHT_VERSION in" >&2
echo " docker/ci/Dockerfile to match, let ci-image.yml publish the new :<sha>, then update" >&2
echo " all five container pins in docker-build.yml (docs/ci-cd.md -> 'CI toolchain image')." >&2
echo " Locally: cd web && npx playwright install chromium" >&2
exit 1
fi
rm -f "$PW_PROBE_ERR"
# No port pre-flight here: `scripts/e2e-local.sh` owns that since ersatztv#586/#598, and its version
# is strictly better — it probes BOTH bound ports (the app binds a second, streaming listener that
# otherwise defaults to 8409 regardless of ETV_UI_PORT) and it REPORTS rather than reaps. Duplicating
# it here would mean two divergent checks with different messages on the same concern.
#
# Note the app also enforces a SINGLE-INSTANCE guard of its own ("Another instance of ErsatztTV is
# already running"), independent of ports — so a stray instance anywhere blocks a run regardless of
# which port you pick. Kill the leftover BY PID; never `pkill -f dotnet ErsatzTV.dll` (#586).
# --- Server lifecycle -------------------------------------------------------------------------
#
# The trap is installed BEFORE the server is booted, not after. A signal (or any early exit)
# arriving while `e2e-local.sh` is still starting the app would otherwise leave a live server bound
# to the port with no handler to reap it — precisely the "stray process" failure this script exists
# to prevent.
#
# `wait "$PID"` cannot be used to confirm the kill: e2e-local.sh launches the app inside its own
# subshell and then exits, so the process is NOT a child of THIS shell — `wait` fails instantly
# ("not a child of this shell") and returns before the port is released. Poll `kill -0` instead,
# then escalate to SIGKILL.
SERVER_PID=""
# e2e-local.sh writes the server PID here the instant it forks, BEFORE its readiness wait. That
# closes the mid-boot window: if this script is signalled while the app is still starting, we have
# never parsed stdout, but the pidfile already names the process.
#
# This replaces an earlier "kill whatever LISTENS on $PORT" fallback, which was wrong on two counts:
# it needed `lsof` (ABSENT from the CI toolchain image — verified — so it silently no-opped exactly
# where it was needed), and it inferred ownership from the pre-flight instead of proving it, so a
# process that grabbed the port after the pre-flight — or a real instance on a shared host — could be
# killed. A pidfile we asked for proves ownership outright and needs no external tool.
PIDFILE="$(mktemp)"
export ETV_PIDFILE="$PIDFILE"
# Reap $1 with a bounded graceful wait, then SIGKILL. `wait` is unusable here: the server is not a
# child of THIS shell (e2e-local.sh forked it and exited), so `wait` fails instantly and would return
# before the port is released.
reap() {
local pid="$1"
kill "$pid" 2>/dev/null || true
local i=0
while [ "$i" -lt 50 ]; do # up to ~5s
kill -0 "$pid" 2>/dev/null || return 0
sleep 0.1
i=$((i + 1))
done
echo "warning: server $pid did not exit after SIGTERM; sending SIGKILL" >&2
kill -9 "$pid" 2>/dev/null || true
}
read_pidfile() {
[ -s "$PIDFILE" ] || return 0
tr -dc '0-9' <"$PIDFILE" 2>/dev/null || true
}
cleanup() {
# ORDER MATTERS. Read the pidfile BEFORE touching the launcher: killing e2e-local.sh first could
# land in the window between its fork and its pidfile write, losing the only handle on a server
# that is already running.
local pid="$SERVER_PID"
[ -n "$pid" ] || pid="$(read_pidfile)"
# Now stop the backgrounded launcher so it can't keep booting/waiting on a server we're killing.
# Best-effort only — it is never the primary handle on the server (see the grace re-read below).
if [ -n "${BOOT_PID:-}" ]; then
kill "$BOOT_PID" 2>/dev/null || true
BOOT_PID=""
fi
rm -f "${BOOT_OUT:-}" 2>/dev/null || true
# Grace re-read: a signal can land in the sliver between e2e-local.sh forking the server and
# publishing its PID (or between `... &` and this script's `BOOT_PID=$!`). In those cases the
# pidfile is empty at first read but populated microseconds later. Without this, that server is
# orphaned. Bounded at ~1s so a genuinely server-less abort still exits promptly.
if [ -z "$pid" ]; then
local i=0
while [ "$i" -lt 20 ]; do
pid="$(read_pidfile)"
[ -n "$pid" ] && break
sleep 0.05
i=$((i + 1))
done
fi
SERVER_PID=""
rm -f "$PIDFILE"
[ -n "$pid" ] || return 0
reap "$pid"
}
# Re-raise INT/TERM after cleaning up, so the wrapper dies BY the signal rather than exiting 0.
# Without this a TERM landing next to a passing Playwright run reports success for a cancelled run.
on_signal() {
cleanup
trap - EXIT "$1"
kill -"$1" $$
}
trap cleanup EXIT
trap 'on_signal INT' INT
trap 'on_signal TERM' TERM
echo "Booting instance for UI-E2E (config: $CONFIG_DIR, port: $PORT)..."
# Boot in the BACKGROUND and `wait` on it, rather than the obvious
# `OUT="$(... e2e-local.sh ...)"`. Bash defers a trapped signal until the current FOREGROUND command
# finishes, so with a command substitution a TERM arriving during the (up to 120s) readiness wait
# would not run `on_signal` until boot completed — and a supervisor that escalates TERM->KILL would
# mean the trap never runs at all, orphaning the server on its port. `wait` is interruptible: the
# handler runs immediately, and by then e2e-local.sh has already written $ETV_PIDFILE, so cleanup
# can reap a server whose PID this script has not yet parsed.
BOOT_OUT="$(mktemp)"
ETV_UI_PORT="$PORT" "$REPO_ROOT/scripts/e2e-local.sh" "$CONFIG_DIR" >"$BOOT_OUT" 2>&1 &
BOOT_PID=$!
set +e
wait "$BOOT_PID"
boot_status=$?
set -e
# Clear it the moment `wait` reaps the launcher. A stale PID here is not merely useless — the number
# can be RECYCLED by the OS during a long spec run, and cleanup would then SIGTERM an unrelated
# process. (Same "never kill something you didn't prove is yours" rule that removed the earlier
# port-based reap.)
BOOT_PID=""
OUT="$(cat "$BOOT_OUT")"
rm -f "$BOOT_OUT"
if [ "$boot_status" -ne 0 ]; then
echo "error: e2e-local.sh failed to boot the instance (exit $boot_status). Its output:" >&2
printf '%s\n' "$OUT" >&2
exit "$boot_status"
fi
printf '%s\n' "$OUT"
SERVER_PID="$(printf '%s\n' "$OUT" | awk -F= '/^PID=/{print $2}')"
SERVER_LOG="$(printf '%s\n' "$OUT" | awk -F= '/^LOG=/{print $2}')"
if [ -z "$SERVER_PID" ]; then
echo "error: could not determine the server PID from e2e-local.sh output" >&2
exit 1 # the EXIT trap's port-based fallback reaps the server we just started
fi
echo "Running Playwright UI-E2E specs against http://localhost:${PORT} ..."
cd "$REPO_ROOT/web"
# Capture the status WITHOUT `if ! cmd; then` — under `!` negation bash sets `$?` to the *logical
# negation*, so `$?` reads 0 inside the failure branch and the script would `exit 0` on a FAILING
# spec run, silently passing CI. (Verified: `if ! (exit 42); then echo $?; fi` prints 0.) `set +e`
# around the call, then read `$?` directly, is unambiguous.
set +e
ETV_BASE_URL="http://localhost:${PORT}" npx --no-install playwright test "$@"
status=$?
set -e
if [ "$status" -ne 0 ]; then
# No workflow in this repo uploads artifacts, so traces/screenshots in web/e2e/.output never leave
# the runner. The server log is the only server-side evidence a CI failure would otherwise lack.
if [ -n "${SERVER_LOG:-}" ] && [ -f "$SERVER_LOG" ]; then
echo "--- server log tail ($SERVER_LOG) ---" >&2
tail -n 40 "$SERVER_LOG" >&2 || true
echo "--- end server log ---" >&2
fi
fi
exit "$status"
+141
View File
@@ -0,0 +1,141 @@
import { expect, test, type Page } from '@playwright/test';
// UI-interactive boot-gate flows (ersatztv#445), deferred from #363 because they cannot be expressed
// as curl calls. `scripts/e2e-functional.sh` already asserts the auth *HTTP* contracts (setup-claim
// 200/409, login 401/200, CSRF 403, security-stamp rotation). What it CANNOT reach, and what these
// specs exist for:
//
// 1. Client-side form validation — the Setup card's confirm-password gate is pure React state.
// No request is made, so there is no HTTP contract to assert.
// 2. AuthGate's state machine as rendered — that `setupRequired` yields the Setup card, a
// configured server yields the Login card, and a 401 yields neither.
// 3. The session cookie authenticating the SPA's OWN fetch layer. curl proves the cookie works for
// curl; it cannot prove the browser sends it on the app's `/api/v1/*` XHRs, which is the thing
// that actually breaks for a user.
// 4. Sign-out wired through the UserMenu back to the login gate.
//
// SERVER STATE IS SHARED AND PARTLY ONE-SHOT. The setup-claim can happen exactly once per config
// dir, so these tests are `serial` and the claim must run first. `scripts/e2e-ui.sh` boots a FRESH
// instance so the first test always finds `setupRequired: true`.
//
// Each `test` gets its own browser context (so its own cookie jar) — that is deliberate: it gives
// tests 2 and 3 a genuinely signed-out browser without a logout dance. Anything that depends on
// holding a session across steps must therefore stay INSIDE one test.
const USERNAME = 'e2eadmin';
const PASSWORD = 'e2e-Passw0rd!';
// The Setup/Login cards use the shared <Input>, which wraps its <input> in a <label> — so the
// accessible name picks up the error text too when a field is invalid ("Confirm password Passwords
// do not match."). Address the fields by their unique placeholders instead, which are stable
// regardless of validation state.
const setupUsername = (page: Page) => page.getByPlaceholder('Choose a username');
const setupPassword = (page: Page) => page.getByPlaceholder('Choose a password');
const setupConfirm = (page: Page) => page.getByPlaceholder('Re-enter the password');
const loginUsername = (page: Page) => page.getByPlaceholder('Username', { exact: true });
const loginPassword = (page: Page) => page.getByPlaceholder('Password', { exact: true });
/** The authenticated landmark: the TopBar UserMenu button renders the signed-in username. */
const userMenu = (page: Page) => page.getByRole('button', { name: new RegExp(USERNAME) });
/** Shown by UnauthorizedBanner on any 401 from the SPA's fetch layer. Must never appear when authed. */
const authBanner = (page: Page) => page.getByText('Your session has expired or you are signed out.');
test.describe.serial('boot gate', () => {
test('setup: a fresh instance gates on Setup, enforces confirmation, and the claim enters the app', async ({
page
}) => {
await page.goto('/app');
// (1) A fresh config dir => `/api/v1/auth/config.setupRequired` is true => the Setup card.
await expect(page.getByText('Welcome to ChicoryTV')).toBeVisible();
await expect(page.getByText('Create the first administrator account')).toBeVisible();
const submit = page.getByRole('button', { name: 'Create account' });
// (2) Client-side confirm-password validation — no HTTP request is involved, so this contract
// exists ONLY in the browser. Submit stays disabled until the two passwords actually match.
await expect(submit).toBeDisabled();
await setupUsername(page).fill(USERNAME);
await setupPassword(page).fill(PASSWORD);
await expect(submit).toBeDisabled(); // confirm still empty
await setupConfirm(page).fill(`${PASSWORD}-mismatch`);
await expect(page.getByText('Passwords do not match.')).toBeVisible();
await expect(submit).toBeDisabled();
await setupConfirm(page).fill(PASSWORD);
await expect(page.getByText('Passwords do not match.')).toBeHidden();
await expect(submit).toBeEnabled();
// (3) The claim returns a session and AuthGate hands off to the app shell.
await submit.click();
await expect(userMenu(page)).toBeVisible();
// (4) The cookie survives a full reload — AuthGate's `/auth/session` path, not the claim path.
await page.reload();
await expect(userMenu(page)).toBeVisible();
await expect(page.getByText('Welcome back')).toBeHidden();
// (5) The session authenticates the SPA's OWN `/api/v1/*` fetches — the assertion curl
// structurally cannot make. Navigating to Channels must render server data, not the 401 banner.
//
// NOTE: a fresh config is NOT empty. `DbInitializer` seeds one default channel (Number "1",
// Name "ErsatzTV") on every new DB, so the Channels *empty state* is unreachable here — assert
// the seeded row instead. Asserting the table content (not merely "the screen rendered") is what
// makes this prove the fetch succeeded: an unauthenticated fetch yields a table with no rows.
await page.goto('/app/channels');
await expect(page.getByRole('heading', { level: 1, name: 'Channels' })).toBeVisible();
await expect(page.getByRole('table', { name: 'Channels lineup' })).toBeVisible();
await expect(page.getByRole('cell', { name: /ErsatzTV/ }).first()).toBeVisible();
await expect(page.getByText('1 of 1 channels')).toBeVisible();
await expect(authBanner(page)).toBeHidden();
});
test('login: a configured server gates on Login; a wrong password errors inline', async ({ page }) => {
await page.goto('/app');
// Setup is claimed, so this signed-out browser must get Login — NOT Setup, and not the app.
await expect(page.getByText('Welcome back')).toBeVisible();
await expect(page.getByText('Create the first administrator account')).toBeHidden();
const submit = page.getByRole('button', { name: 'Sign in' });
await expect(submit).toBeDisabled();
await loginUsername(page).fill(USERNAME);
await loginPassword(page).fill('definitely-the-wrong-password');
await submit.click();
// A 401 renders inline on the card; login() suppresses the global banner for exactly this case.
await expect(page.getByText('Invalid username or password.')).toBeVisible();
await expect(authBanner(page)).toBeHidden();
await expect(userMenu(page)).toBeHidden();
// The same form then accepts the correct password (proving the 401 was credential-specific).
await loginPassword(page).fill(PASSWORD);
await expect(page.getByText('Invalid username or password.')).toBeHidden();
await submit.click();
await expect(userMenu(page)).toBeVisible();
});
test('sign out through the UserMenu returns to the Login gate', async ({ page }) => {
await page.goto('/app');
await loginUsername(page).fill(USERNAME);
await loginPassword(page).fill(PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(userMenu(page)).toBeVisible();
await userMenu(page).click();
await page.getByRole('button', { name: 'Sign out' }).click();
// Back to the login card, and the app shell is gone.
await expect(page.getByText('Welcome back')).toBeVisible();
await expect(userMenu(page)).toBeHidden();
// The revoked cookie must not resurrect the session on a reload (rotated security stamp).
await page.reload();
await expect(page.getByText('Welcome back')).toBeVisible();
});
});
+64
View File
@@ -18,6 +18,7 @@
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.0",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@types/node": "26.1.0",
@@ -815,6 +816,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
@@ -3369,6 +3386,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+2
View File
@@ -10,6 +10,7 @@
"check:api": "npm run generate:api && git diff --exit-code -- src/api/generated/v1.d.ts",
"lint": "eslint .",
"test": "vitest",
"test:ui-e2e": "playwright test",
"typecheck": "tsc -b --pretty false",
"prepare": "cd .. && husky"
},
@@ -30,6 +31,7 @@
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.0",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@types/node": "26.1.0",
+41
View File
@@ -0,0 +1,41 @@
import process from 'node:process';
import { defineConfig, devices } from '@playwright/test';
// UI-E2E config (ersatztv#445). These specs drive a REAL headless Chromium against a REAL running
// instance — the handful of contracts that `scripts/e2e-functional.sh`'s curl harness cannot express
// (see docs/e2e-local.md → "UI-E2E harness").
//
// Deliberate choices:
// - NO `webServer`. The instance is booted by `scripts/e2e-ui.sh` (via `scripts/e2e-local.sh`), which
// owns the wwwroot copy + readiness probe + a FRESH config dir. Letting Playwright boot the app
// would duplicate that logic and lose the fresh-config guarantee the setup-claim flow depends on.
// - `retries: 0` everywhere, including CI. #445 asks for *deterministic* flows; a retry would let a
// genuinely flaky flow merge looking green. A failure here should be read as a real defect.
// - Chromium only. This is a boot-gate/session smoke, not a cross-browser matrix; the browser is
// baked into the CI toolchain image (docker/ci/Dockerfile) and one browser keeps that image lean.
// - `workers: 1`. Every spec shares ONE server instance whose auth state is global and partly
// one-shot (the setup-claim). Parallel workers would race on that shared state.
export default defineConfig({
testDir: './e2e',
// The SPA is served under /app/ on the same port as the API (no dev server / proxy in this flow).
// ETV_BASE_URL is set by scripts/e2e-ui.sh from the port e2e-local.sh actually bound.
use: {
baseURL: process.env.ETV_BASE_URL ?? 'http://localhost:8409',
trace: 'retain-on-failure',
screenshot: 'only-on-failure'
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
fullyParallel: false,
workers: 1,
retries: 0,
// Fail fast in CI rather than burning the job on a hung flow; each of these flows is seconds.
timeout: 30_000,
expect: { timeout: 10_000 },
forbidOnly: !!process.env.CI,
// `list` only, in CI too. No workflow in this repo uploads artifacts, so an `html` report would be
// written and then discarded with the runner; the list reporter's inline failure output (assertion +
// call log + the accessibility snapshot Playwright prints) is what actually lands in the CI log.
// `trace`/`screenshot` above still write into outputDir, which is what a local re-run wants.
reporter: [['list']],
outputDir: './e2e/.output'
});
+4 -1
View File
@@ -11,5 +11,8 @@
"skipLibCheck": true,
"types": ["node"]
},
"include": ["vite.config.ts", "eslint.config.js"]
// The Playwright UI-E2E config + specs (ersatztv#445) belong here, not in tsconfig.app.json: they
// run in Node (not the bundled browser app), and `tsc -b` then type-checks them as part of
// `npm run build`, so a broken spec fails the build instead of only failing at test time.
"include": ["vite.config.ts", "eslint.config.js", "playwright.config.ts", "e2e/**/*.ts"]
}
+10 -1
View File
@@ -1,6 +1,6 @@
import { fileURLToPath, URL } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
import { configDefaults, defineConfig } from 'vitest/config';
const aspNetHost = 'http://localhost:8409';
@@ -33,6 +33,15 @@ export default defineConfig({
url: 'http://localhost/app/'
}
},
// `e2e/` holds the Playwright UI-E2E specs (ersatztv#445), which drive a REAL browser against a
// running instance. Vitest's default `include` glob (`**/*.{test,spec}.*`) would otherwise pick
// them up and run them under jsdom, where `@playwright/test`'s `test()` has no runner and the
// whole file dies. Spread the defaults rather than replacing `exclude` outright — a bare
// `exclude: ['e2e/**']` would drop `**/node_modules/**` and make vitest crawl deps.
// NOTE: excluding (not narrowing `include` to `src/**`) is deliberate — `scripts/` holds a real
// vitest test too (`generate-openapi-types.test.mjs`), which an `src/**`-only include would
// silently stop running.
exclude: [...configDefaults.exclude, 'e2e/**'],
setupFiles: './src/setupTests.ts'
}
});