feat(445,533): headless Playwright UI-E2E flows + fix e2e-local readiness probe #591
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Executable
+226
@@ -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"
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Generated
+64
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user