---
key: ci.ui-e2e-harness
title: '2026-07-25 — UI-E2E: headless Playwright flows in the existing `functional-e2e` job, browser baked into the CI image (#445)'
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.
