---
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.
