--- key: ci.functional-e2e-harness title: '2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)' 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 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`' --- The manual live-E2E curl flows sessions had been re-running by hand (and leaving only as PR/issue comments) are now a CI regression net. Two decisions shaped it: **Boot from source + `dotnet run`, not the built image.** The only "E2E" in CI before this was the smoke test in the `build` job, which runs against the *pushed* image — so it exists only on `main`/`v*` (the image isn't built on PRs) and would test a stale image, not the PR's code. To gate PRs on the PR's own code, the `functional-e2e` job builds the SPA + solution and launches `dotnet ErsatzTV.dll` via the same `scripts/e2e-local.sh` used locally (parameterized with `ETV_BUILD_CONFIG=Release`). The assertions live in `scripts/e2e-functional.sh`, so the identical harness runs by hand and in CI — which is the point of the issue (stop re-deriving the flows each session). **Advisory, not blocking — separate job, not a `build` dependency, not a required check.** Per the issue's "a functional-E2E flake must not block the unit-test gate." A boot-the-app job has more moving parts (background process, port, readiness wait) than a pure unit test, so it starts advisory and gets promoted to a required check / `build` dependency once proven reliable — the same staged rollout the `migrations` job used. SQLite is the default provider, so it needs no DB service container. **Scope is curl-only and deterministic; the racy/interactive flows are explicitly deferred.** The first cut asserts the legacy→SPA redirect sweep (+ `/api`/`/artwork` never-redirect exemption), the auth/CSRF/security-stamp flow, the library-scan status contract (404/202/`scan-status`), and the `If-Match`/412 round-trip — all exercisable without seeded media, ffmpeg-transcode, or a browser (an 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. (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.