Files
ersatztv/docs/decisions/records/ci/ui-e2e-harness.md
T
timothyandClaude Fable 5.1 692affca97 fix(911): a round with no reviews is an error, the history binds each fix to the round it answered, and a harness executes the loop
A round in which every lens returned null read as a clean pass and would now
have been quoted verbatim into the PR body; it is an error before the push,
in the review loop and in the post-rebase round, which also gains the same
blocking-or-should-fix filter; a fixer that dies or stops (no done) is an
error too, the same test the implementer already gets. The history entry now
carries the fix that answered that round and only the commits that fix added
(a line-set difference against the previous branch log — a fixer that
reformats or rebases mid-loop defeats it, which is why the finisher is told
to read git show, not the list). An empty fix-commit set is described as
"answered without a new commit" when a fix round ran, and as "round one was
clean" only when none did.

web/scripts/orchestration-workflow-loop.test.mjs compiles the committed script
bodies with stubbed agent/parallel and pins eleven paths per script (22
tests). Measured: reverting the loop condition to blocking-only reddens six
cases per script (every case that needs a should-fix round to reach the
fixer); deleting any of the three zero-lens guards, the fixer guard or its
done half, or the empty-fix sentence branch reddens its own case, in both
scripts. web/vite.config.ts is untouched: it is pinned whole by
test_image_build_delegates_the_spa_suite.py, comments included.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 02:50:23 +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 tests 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.