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