ADR 0120 — Browser E2E against the production compose, in a package outside the workspace
- Status: accepted
- Date: 2026-08-29
- References (without editing): ADR 0032 (the cookie session this layer exists to prove), ADR 0117 (the precedent for a dev-only subtree with its own lockfile), ADR 0027 (the production smoke this reuses instead of duplicating).
Context
The test pyramid was strong at the bottom and had a hole at the top. The
api has 142 specs, the engine 126, the web tests component by component
under jsdom, and docker/smoke.sh proves over HTTP that the four
production images boot and talk to each other.
None of those exercises a browser. And the mechanisms that carry the session are browser mechanisms:
- the access token lives in memory and the refresh in an
httpOnlycookie, paired with a readablebrabo_csrfsent asX-CSRF-Token; - the web is served from
:8088and the api answers on:3000, so every authenticated call is cross-origin — preflight,SameSite,credentials: 'include'; - the session channel needs a single-use ticket (RN-108) and a real WebSocket handshake against the engine, on a third origin.
jsdom has none of that. It has no real origin, does no preflight, and
httpOnly is not a thing it can enforce — a test asserting the refresh
is unreadable would pass there while proving nothing. apps/api/src/main.ts
already carries the comment that says it plainly: "teste não faz preflight."
This is not a hypothetical gap. It's where the last cookie, CORS and socket bugs actually appeared, and each was found by hand.
Decision
A browser E2E layer, in e2e/, running against the production compose
that CI already brings up.
Three parts to the decision, each with its own reason.
It runs against docker-compose.prod.yml, not vite dev
The cross-origin split (:8088 → :3000 → :4000) exists only in the
production compose. Running against the dev server would exercise a
topology nobody deploys and would silently stop proving the one thing
this layer is for. So the job reuses docker/smoke.sh with
SMOKE_KEEP_UP=1: same images, same compose, one build.
It also runs in the same CI job as the images. The four images are most of that job's wall clock; a separate job would rebuild them to exercise the identical stack.
e2e/ is not a member of the pnpm workspace
Same shape as website/ (ADR 0117):
its own pnpm-workspace.yaml and pnpm-lock.yaml, installed with
pnpm install from inside the folder, driven from the root by
pnpm --dir e2e.
The reason is the same one: Playwright's tree never reaches any product
image, and leaving it in the root lockfile would make the product's
pnpm audit report test tooling as if it were shipped surface. The
difference from the website — that this package tests the product —
doesn't change the argument, because what decides is where the dependency
goes, not what it talks about.
It proves mechanisms, not screens
The specs assert on observable mechanism: the httpOnly flag as the
browser reports it, document.cookie as the page sees it, survival
across a reload, the WebSocket URL carrying ticket=. Selectors are
structural (input[type="email"]), never text — the interface language
is decided by the server, and a test pinned to "Sign in" would break on a
language change, a failure that says nothing about the product.
Seeding (workspace → project → session) goes over HTTP, mirroring
smoke.sh. The browser is expensive and the project wizard is not what
this layer exists to prove; slow setup is setup that gets switched off.
Consequences
- The gap closes for the paths that only a browser reaches. Both specs were proven by mutation: a wrong password turns the auth specs red, and pointing the socket assertion at a path that doesn't exist fails with "nenhum WebSocket foi aberto contra o engine."
- The
imagesjob grows: pnpm/Node setup, a chromium download, and the run itself. In exchange it stays one job and one image build. - Chromium only. One browser proves the mechanism; the other two would cost download and time to prove the same thing again. Cross-browser differences are a real risk this decision declares it is not covering.
- The browser binary is downloaded at CI time from Playwright's CDN. Its
version is pinned by the exact
@playwright/testversion in the lockfile, which is how this download fits the repository's pinning discipline — see cadeia-de-suprimentos-do-ci.md. - One retry in CI, none locally. E2E against a live stack has flake with external causes (a container still coming up, a slow socket). Zero retries would turn that into noise that teaches people to ignore red.
- Two specs is a floor, not a suite. Inline approval and streaming — named in the original finding — are not covered yet.
- The suite has a login budget, and it came from running it for real.
The api defends
/auth/loginwith a progressive per-IP lockout that answers with the same uniform 401 as a wrong password — distinguishing them would tell an attacker when they guessed the email right. Running the suite repeatedly inside the 15-minute window trips it, and the failure then accuses the login, which is not where the defect is. Two consequences were taken: the browser logs in once per run (asetupproject saves the state; onlyautenticacao.spec.tsopts out, because proving login needs a clean origin), andsuporte/api.tsrecognizes that 401 and says what it probably is. The alternative — asking the compose for a laxer threshold so the suite could log in freely — was rejected: it would weaken the thing being tested to make testing convenient.
Alternatives considered
e2e/ inside the pnpm workspace. One install, no second lockfile.
Rejected for the reason ADR 0117 had just finished paying for: the
product's audit would carry Playwright's tree.
Run against vite dev. Faster, no images to build. Rejected because
it would test a topology that is not deployed, and the same-origin dev
server makes every assertion in autenticacao.spec.ts vacuous.
A dedicated E2E job. Cleaner separation, and a failure there wouldn't be reported under "images". Rejected on cost: it would rebuild the four images to reach the same compose.
Drive project creation through the wizard instead of seeding by HTTP. More realistic, and it would cover the wizard. Rejected for now — it would make the run several times longer and couple this layer to a git provider, for coverage that belongs to a wizard-specific spec if one is ever wanted.