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