Files
ersatztv/docs/decisions/records/ci/ui-e2e-harness.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

4.7 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
ci.ui-e2e-harness 2026-07-25 — UI-E2E: headless Playwright flows in the existing `functional-e2e` job, browser baked into the CI image (#445) active 2026-07-25 none none 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. 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 `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.