Merge pull request #615 from fsolla/OPS30-hardening-e2e-em-dev-prewarm-da-rota-editar-login-resi

chore(OPS30): hardening e2e em dev — prewarm completo, login resiliente a compile-frio, fechamento do P4-L
This commit is contained in:
Francisco Solla 2026-08-12 13:44:23 -03:00 committed by GitHub
commit 38f6d74483
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 274 additions and 34 deletions

View file

@ -49,6 +49,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-10):** **OPS28** (Issue #584) — `worktree next`/`plan` passam a aplicar o **seed mínimo** além das migrations em cada banco provisionado (`migrate` + `db:seed:minimal` por banco, dev e test) — paridade com a CI, que roda o mesmo par antes do e2e: o e2e de worktree deixa de divergir do de CI (specs dependentes dos pins `priority: 'alta'` de `salvador-ze-1`/`camacari` — FAB suggest e `campaignSavedFilters` — falhavam só localmente com banco migrado cru 435×normal, o padrão que o ledger TECH-DEBT row 19 ficha como "masks real failures"). `--no-migrate` pula migrate **e** seed (o seed depende do catálogo migrado); re-provisionamento continua idempotente (upserts). Fallback sem Docker (Cursor Cloud) também roda migrate+seed nos `teqo`/`teqo_test` compartilhados (decisão de produto do gate: paridade total). **Guard de regressão** `tests/e2e/seedParity.setup.e2e.spec.ts` no projeto `setup` do Playwright: falha cedo com mensagem clara ("rode `pnpm migrate && pnpm db:seed:minimal`") se o banco de teste não estiver seedado — dependentes do setup pulam, nada de spec vermelho confuso; a evidência checada são os **users do seed** (`MINIMAL_CAMPAIGN_USERS`), deliberadamente não os pins `priority: 'alta'` — o cleanup dos fixtures e2e reseta `priority` dos municípios que toca (round-robin por sequência persistente), então pins morrem legitimamente após runs repetidas no mesmo banco de worktree e só o `pnpm worktree next` re-aplica (guard nunca checaria pins); `scripts/worktree.mjs` entrou em `HIGH_RISK_EXACT` (mudança no provisionador roda a suíte cheia na CI). Sem migration/access/Consent/UI. Intenção [`worktree-seed-minimal-paridade-e2e-ci.md`](plans/worktree-seed-minimal-paridade-e2e-ci.md); impl [`worktree-seed-minimal-paridade-e2e-ci-impl.md`](plans/worktree-seed-minimal-paridade-e2e-ci-impl.md).
**Recently resolved (2026-08-10):** **OPS30** (Issue #586) — hardening do e2e em dev contra o compile-frio do Next dev (a classe que faz `pnpm test:e2e` local acusar vermelho com `main` verde). **Prewarm completo no setup** (`setup.e2e.spec.ts`): lista GET 13→26 rotas (o audit por grep achou `/campanha/municipios/<slug>/editar` — o flake ERR_ABORTED de `:78` —, alvos de redirect invisíveis a grep de goto como `/campanha/demandas/[slug]`, rotas de wizard `acoes/*`, agenda/atividades/nova/contatos/atualizacoes/offline e os artefatos PWA `sw.js`/`manifest.webmanifest` que toda página busca no load) e POST ganhou `/campanha/municipios/next-steps`, `/campanha/home-search` (suggest do overlay FAB) e as rotas do chat Sollinha `/campanha/api/ai-chat`+`ai-transcribe` (montadas em toda página autenticada — 1º hit compila frio no meio da suíte e o reload aborta a navegação de um teste irmão). **Prewarm GET com retry-único** (`prewarmGet`): o próprio compile pode abortar a conexão do prewarm (socket hang up medido na lista original sob load ~50); asserção só no retry. Budget `test.slow()`→`setTimeout(420s)` (26 módulos × ~60s frios sob carga; CI quente em segundos). **Login resiliente** (`campaign.login`): retry-único do submit inteiro (re-fill+re-click) com grace-wait de 5s do destino exato antes do re-submit (guard de prefixo casava `/campanha/login` — corrigido); budget sozinho não recupera submit abortado. **Refactor mínimo do spec `campaignMunicipalities`** (evidência de execução): `:518` (B24) — botão de tendência só existe no estágio largo B158 e o locator pegava a cópia mobile escondida → `ensureWideMunicipalityList`+escopo container+`.filter(visible)`; re-clique pós-reload era pré-hydration (P3-C) e o trigger **toggles** (toPass com clique cego fecha o popover para sempre) → `networkidle` pós-reload + clique só com popover fechado; `:713` (demanda) — server action de criação compila fria no 1º POST (não prewarmável) → retry do submit em toPass; `:1063` (FAB suggest) — **root cause = paridade de seed**: o CI roda `db:seed:minimal` (pinna `priority:'alta'` em salvador-ze-1/camacari) e o suggest do coordinator é vazio sem isso → achado registrado para o OPS28 (worktrees provisionam DBs e2e sem o seed mínimo, em progresso); retry blur+focus do suggest em toPass como defesa do stream truncado. **P4-L fechado com evidência** (TECH-DEBT row 59): `campaignLeaderships` ×3 verde em dev a 2 workers (load 4–10) + ×1 `E2E_PROD=1`; TESTING.md caveat atualizado. Aceite: `campaignMunicipalities` **17/17 ×3 consecutivos em dev a 2 workers** (16:00–16:03, load 5–18) — antes: `:78` 3/4 vermelho e `:518` 1/2. Sem migration/access/Consent/UI. Intenção [`hardening-e2e-dev-compile-frio.md`](plans/hardening-e2e-dev-compile-frio.md); impl [`hardening-e2e-dev-compile-frio-impl.md`](plans/hardening-e2e-dev-compile-frio-impl.md).
**Recently resolved (2026-08-10):** **OPS34** (Issue #599) — o job `e2e` do CI (o mais longo da cascata, 435 s medidos no run 31412425553: ~67 s fixos + build 172 s + testes 196 s) roda agora **em 2 shards paralelos** (`--shard=N/2`, matrix `fail-fast: false`, build por shard — opção A da intenção validada pela medição: por shard ≈ 5–5,5 min; artefato de build compartilhado rejeitado porque os shards só começam após o build → wall maior). **Cadeia de projetos do Playwright (`setup → campaign → frontend → admin`) + prewarm do `setup` viraram dev-only:** `playwright.config.ts` ganhou `isProdMode = CI || E2E_PROD=1` (mesma condição do `webServer.command`, agora reusada) — em prod o projeto `setup` sai do array (arquivo não casado = não coletado) e as `dependencies` zeram (famílias em paralelo contra o build de produção, onde não há compile a preaquecer); dev intocado (provado: `--list` mostra `[setup]` só sem CI; smoke e2e dev rodou setup→campaignAuth). `ci-pr.yml`: o job `scope` emite `e2e_matrix`/`e2e_shards` via novo `e2eShardConfig(mode)` em `scripts/lib/test-affected-core.mjs` (fonte única, unit-pinada: full → `[1,2]`/2, selected/none → `[1]`/1 — pagar 2 builds por 3 specs é desperdício); `ci.yml` hardcodeia `[1,2]`/2 com comentário apontando o single-source. `PLAYWRIGHT_WORKERS: 2` explícito no env dos jobs e2e (aceite: explícito, default local intocado). Armadilha registrada nos workflows: flags do playwright vão **sem** o separador `--` — pnpm repassa o `--` literal ao playwright e a flag vira file pattern ("No tests found"). Specs intactas (o `setup` não foi removido — virou dev-only, já documentado como prewarm dev-only); sem migration/access/Consent/UI. **Medição pós-merge (main, run 31418206512):** job e2e 435 s → **326 s (shard 1) / 369 s (shard 2)** — shard 2 = fixo 68 s + build 176 s + testes 124 s (a fase de testes caiu de 196 s seriais para 124 s — metade da suite por runner + cadeia removida; o shard 2 ainda pagou retry da flake conhecida de `campaignMunicipalities`; shard 1: testes ~82 s); gate completo do pipeline (checks) ~7,5 → ~6,4 min. Build passou a dominar o shard — gatilho da intenção (opção B, artefato compartilhado) reavaliável quando os testes encolherem mais. Intenção [`ci-e2e-paralelizar-job.md`](plans/ci-e2e-paralelizar-job.md); impl [`ci-e2e-paralelizar-job-impl.md`](plans/ci-e2e-paralelizar-job-impl.md).
**Recently resolved (2026-08-10):** **OPS22** (Issue #562) — CI de `main` vermelho determinístico desde o merge do B188 (05:54): os 3 e2e da agenda mobile (`campaignAgendaMobile` :96/:242/:295) falhavam com todas as retries → deploy bloqueado. **Root cause (reproduzida no prod build local + error-contexts com o drawer aberto):** o login e2e roda em **viewport desktop** → o settle de B167 abre o chat (`open=true`) → o **efeito persist do B188** (status `ready` já nasce) grava `{open:true}` no `sessionStorage` → o spec muda para 390px na **mesma aba** → o **restore do B188** reabre o **drawer mobile sozinho** → o modal Radix **aria-hide + inert** a página e cobre o viewport (combobox fora da a11y tree no :242, swipe cai no drawer no :96, clique do seletor bloqueado no :295; teclado/:173 passam por não usarem hit-testing). **Defeito real de produto** (aba desktop → mobile na mesma aba faz o chat "saltar"), não só timing de teste. **Fix na origem:** `SollinhaChatSession` ganhou **`openBy: 'user' | 'settle'`** (opcional — sessão legada sem o campo = `'settle'`, fail-closed; sem bump de versão) e o provider agora distingue **intenção do usuário** (`setOpen`/`toggle` do context value marcam `userToggledOpenRef`; FAB/header/drawer já passam por eles) de **verdade de layout** (settle B167 usa o setter cru): o **restore espera o `measured`** (viewport real) e honra `open:true` em mobile **só quando `openBy==='user'`**; o persist grava `openBy` conforme a intenção. Todos os contratos B188/resize mantidos (reload restaura conversa; drawer aberto por usuário volta aberto; visita mobile nova fechada; crossing ao vivo B167 intocado). Testes: unit (round-trip/invalidez/legado de `openBy`) + **e2e pin OPS22** no spec B188 (login desktop → poll do persist `open:true` → viewport 390 → `/campanha/agenda` → sem dialog + botão do Sollinha visível); spec da agenda intacto (4/4 no prod build). Sem migration/access/Consent/schema. Intenção [`ci-e2e-agenda-mobile-regressao-b188.md`](plans/ci-e2e-agenda-mobile-regressao-b188.md); impl [`ci-e2e-agenda-mobile-regressao-b188-impl.md`](plans/ci-e2e-agenda-mobile-regressao-b188-impl.md).

View file

@ -56,7 +56,7 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| `HomeSearchProvider` wraps the whole chrome incl. `actions` + `summarySlot` non-consumers, and the provider value changes on every keystroke | `src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx:41-56`, `useHomeSearchQuery.ts:63-74` | state placement | low — actions/summary re-render per keypress | S | P2 | open — Pass 4 | P4-G |
| Unconditional `aria-live="polite"` per chip cell (~50 always-registered regions on lideranças); the overlay family already lazy-mounts its region from first open | `src/components/campaign/shared/RelationChipCell.tsx:821-823` vs `CampaignCellEditOverlay.tsx:114-120` | a11y scale | low | S | P2 | open — Pass 4 | P4-G |
| Bypass ratchet dodge: one "bypass" comment in the first 40 lines documents **every** `overrideAccess: true` in the file — `notification/*` has 20, `campaignWebAuthnCeremony` 4, zero per-call justifications | `codebaseConventions.unit.spec.ts:541-623`; `src/utilities/notification/*`; `src/utilities/webauthn/campaignWebAuthnCeremony.ts` | guard dodge | medium — the P3-E "comment justifying" intent is satisfied in letter, not spirit | M | P2 | open — Pass 4 | P4-H |
| `campaignLeaderships` chip-cell spec fails **deterministically in dev mode** on clean stage (4/4 runs; stash-and-compare 2026-07-31): instrumented with console/response listeners — **no POST fires in 60 s**; signatures vary per run (option visible + click no-ops; option count 1 → 0 after first fill). Prod build green (48/48 full suite ×3 the same day). NOT the P3-C hydration class (input onChange works, listbox renders); a `toPass` retry was attempted and reverted — retrying cannot fix what is not a race | `tests/e2e/campaignLeaderships.e2e.spec.ts:91-101`; suspect surface `RelationChipCell` suggestion state vs dev-mode RSC streaming of `municipalityIndex` | test-infra / possible dev-mode cell bug | medium — a red local signal teaches people to skip the spec | M | P2 | open — Pass 4 (debug workflow, not retry; `E2E_PROD=1` is the honest local mode meanwhile) | P4-L |
| `campaignLeaderships` chip-cell spec fails **deterministically in dev mode** on clean stage (4/4 runs; stash-and-compare 2026-07-31): instrumented with console/response listeners — **no POST fires in 60 s**; signatures vary per run (option visible + click no-ops; option count 1 → 0 after first fill). Prod build green (48/48 full suite ×3 the same day). NOT the P3-C hydration class (input onChange works, listbox renders); a `toPass` retry was attempted and reverted — retrying cannot fix what is not a race | `tests/e2e/campaignLeaderships.e2e.spec.ts:91-101`; suspect surface `RelationChipCell` suggestion state vs dev-mode RSC streaming of `municipalityIndex` | test-infra / possible dev-mode cell bug | medium — a red local signal teaches people to skip the spec | M | P2 | **closed 2026-08-10 (P4-L, OPS30):** the spec was rewritten by B34 since the ledger entry; with the OPS30 hardening (prewarm completo + login retry) it ran **×3 green in dev a 2 workers (16:07–16:09, load 4–10, B32+B34)** + **×1 green com `E2E_PROD=1` (16:12, 1.6s/1.9s)**. Ledger closed with evidence; reopen if an agent reproduces with the new spec | P4-L |
| God modules on the delta: `municipalityPageData` 503 lines / `loadMunicipalityListPageBundle` 168; `municipalityListUrl` 500 lines / 23 exports (widest interface in utilities); `RelationChipCell` 841 lines / ~11 state hooks (measurement 80, commit 140, render 190) | `src/utilities/municipality/municipalityPageData.ts:307`, `src/utilities/municipality/municipalityListUrl.ts`, `src/components/campaign/shared/RelationChipCell.tsx` | bloater | low | L | P2 | open — Pass 4 (defer + trigger: next facet/sort; next chip relation or AT-scale complaint) | |
| `createSupporter` does not mark `touchedMunicipalities` — a supporter-only fixture path can leave strategy fields dirty on the row | `tests/helpers/campaignFixtures.ts:769-799` | test-infra honesty | low | S | P3 | open — Pass 4 | |
| `(app)/assessores/novo/page.tsx` redirects without `requireCampaignPageActor` — the P3-I guard bans `getCampaignUser()` in pages but not a missing actor helper (1 of 34 pages) | `src/app/(campaign)/campanha/(app)/assessores/novo/page.tsx:1-6` | convention gap | low (layout still session-gates) | S | P3 | open — Pass 4 | |

View file

@ -12,7 +12,7 @@ CI (`.github/workflows/ci-pr.yml`) runs parallel jobs — lint (zero warnings),
**Local e2e before push (same blast radius as PR CI):** `pnpm test:e2e:affected` — runs `e2e-affected.mjs` against `origin/stage`, migrates/seeds, then `pnpm test:e2e` with the selected specs (or the full suite on high-risk diffs). Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode (after fixing the known municipalities-list prod client bug) with `E2E_PROD=1 pnpm test:e2e:affected`.
**Navigation discipline in e2e (miss #54, 2026-07-30):** `page.goto` is for cold loads and URL contracts (deep links, redirects, legacy params); when the scenario is _continuity inside the authenticated shell_ — especially after a heavy list route — navigate in-shell instead (sidebar links, buttons, form submits), the way the user does. Never fire two `page.goto` back-to-back with nothing settling the first navigation (an `await expect(...)`, `waitForURL`, or interaction): the second aborts the first's in-flight RSC fetch (`net::ERR_ABORTED`, measured in `campaignSavedFilters`). The `e2eNavigationConventions` unit guard fails the build on unsettled goto pairs. Two sibling rules of the same family: wait for **interactive state** before clicking dynamic controls — `toBeVisible` proves SSR paint, not hydration (pre-hydration clicks no-op silently; `checkRadixWhenHydrated` is the precedent, P3-C class) — and gate WebAuthn ceremonies on `expectCampaignBiometricsReady` before the island's one-shot probe runs (miss #53). **Dev-mode caveat:** `campaignLeaderships`' chip-cell spec currently fails deterministically under `pnpm dev` (suggestion/commit step; prod build green — run it with `E2E_PROD=1` locally; ledgered in TECH-DEBT).
**Navigation discipline in e2e (miss #54, 2026-07-30):** `page.goto` is for cold loads and URL contracts (deep links, redirects, legacy params); when the scenario is _continuity inside the authenticated shell_ — especially after a heavy list route — navigate in-shell instead (sidebar links, buttons, form submits), the way the user does. Never fire two `page.goto` back-to-back with nothing settling the first navigation (an `await expect(...)`, `waitForURL`, or interaction): the second aborts the first's in-flight RSC fetch (`net::ERR_ABORTED`, measured in `campaignSavedFilters`). The `e2eNavigationConventions` unit guard fails the build on unsettled goto pairs. Two sibling rules of the same family: wait for **interactive state** before clicking dynamic controls — `toBeVisible` proves SSR paint, not hydration (pre-hydration clicks no-op silently; `checkRadixWhenHydrated` is the precedent, P3-C class) — and gate WebAuthn ceremonies on `expectCampaignBiometricsReady` before the island's one-shot probe runs (miss #53). **Dev-mode caveat (closed 2026-08-10, P4-L/OPS30):** `campaignLeaderships`' chip-cell spec was failing deterministically under `pnpm dev` before B34 rewrote it; with the OPS30 prewarm/login hardening it ran ×3 green in dev + ×1 `E2E_PROD=1` (evidence in TECH-DEBT).
## Minimal test database (`pnpm db:seed:minimal`)

View file

@ -0,0 +1,116 @@
# Impl: Hardening e2e em dev: prewarm de rotas frias + login resiliente a compile
Status: em execução (evidência coletada 2026-08-10 — aceite ×3 verde)
Atualizado em: 2026-08-10
Issue: #586
Intenção: docs/plans/hardening-e2e-dev-compile-frio.md
Appetite restante: herdado (~0,5–1 dia eng; um outcome verificável)
## Leitura da intenção
- **Outcome:** `pnpm test:e2e` em dev (worktree, 2 workers) fica verde estável para o que é verde em prod/CI — as duas classes de flake "compile-frio do dev server" (`:78` goto de `/editar` com `ERR_ABORTED`; `:518` `waitForURL` do login estourando 60 s) morrem, e o P4-L fecha com evidência.
- **O que NÃO negociar:** nenhuma asserção de produto muda; o app não muda comportamento para satisfazer o dev server; nada de `waitForLoadState('networkidle')` mágico; padrão local continua `pnpm dev`.
- **O que reavaliar:** (a) a lista de prewarm do setup está **incompleta além do `/editar`** — várias rotas que specs navegam de fato nunca foram prewarms (audit completo abaixo, feito por grep, não palpite); (b) o retry de login só recupera se re-fazer o **submit** inteiro — budget maior sozinho não conserta um submit abortado pelo reload do compile.
## Abordagem recomendada
```mermaid
flowchart LR
A[setup.e2e.spec.ts\nprewarm GET completo] --> B[campaignE2EFixtures.ts\ncampaign.login retry-único do submit]
B --> C[verificação dev x3\ncampaignMunicipalities + campaignLeaderships]
C --> D[verificação prod x1\ncampaignLeaderships E2E_PROD=1]
D --> E[ledger: TECH-DEBT row 59 + TESTING.md caveat]
```
**Opções consideradas (prewarm):** A | B
**Recomendação:** **B** — completar a lista GET do setup com **todos** os módulos de rota que specs de campanha navegam e que hoje faltam, cada um verificado com `response.ok()` no run do setup (o setup já segue esse mecanismo; `e2e-prewarm` é stand-in de slug parametrizado — a intenção diz "as rotas que specs navegam de fato", e o audit mostra que a lista atual não cumpre isso).
**Rejeitadas:** A (só `/editar`) porque deixa a lista com o mesmo defeito estrutural — a próxima rota nova que um spec navegar volta a compilar fria no meio da suíte, exatamente a classe que este item mata.
**Opções consideradas (login):** A | B | C
**Recomendação:** **A** — retry-único do **submit inteiro** (re-fill + re-click + segunda `waitForURL`) dentro do `campaign.login` do fixture; todos os specs herdam (recomendação fechada na intenção).
**Rejeitadas:** B (só budget maior) porque um submit abortado pelo full-page reload do compile nunca navega — `waitForURL` mais longo só adia a falha; C (helper opcional) porque volta a depender de cada spec lembrar de chamar.
### Componentes / mudanças
- **`tests/e2e/setup.e2e.spec.ts`**: lista GET ganha `/campanha/municipios/e2e-prewarm/editar` + os módulos de rota faltantes do audit (ver Fases 1) + os ajustes pós-gate (POSTs novos, PWA/chat, `/campanha/pessoas`). `prewarmGet` com retry-único e budget explícito (ver ajustes pós-gate).
- **`tests/e2e/fixtures/campaignE2EFixtures.ts`** (`campaign.login`, ~546–555): extrai `submitLogin` (fill+click); primeira `waitForURL` no budget padrão; no timeout, se `page.url()` já é `/campanha` → sucesso (idempotência contra double-submit); senão re-fill + re-click + segunda `waitForURL`; segunda falha sobe naturalmente (credencial errada falha duas vezes — retry não mascara defeito real). Comentário documentando o mecanismo do compile-frio.
### Ajustes pós-gate (evidência de execução, 2026-08-10 — mesma sessão)
Sob carga real de worktrees paralelas (load ~50, 16 núcleos — gate do OPS22 rodando em paralelo), a evidência mostrou que **o próprio setup original falhava** (`socket hang up` no 2º GET — a classe de abort do dev server atinge o prewarm quando o compile frio aborta a conexão em voo; reproduzido com a lista original via stash). Dois ajustes no escopo aprovado:
1. **`setup.e2e.spec.ts` — `prewarmGet` com retry-único por GET** (a falha aborta a conexão, mas o compile acontece; o retry cai quente; a asserção `ok()` roda no retry — segunda falha é regressão real). Substituiu `test.slow()` (180 s) por `test.setTimeout(420_000)`: 27 módulos, total medido ~3–6 min de compile frio sob carga (medido: `/campanha/contatos` = 57 s a load ~50) + headroom do retry; no CI (build de produção quente) termina em segundos, o budget não morde.
2. **`campaign.login` — guard de idempotência refinado**: o check `page.url().startsWith(baseURL/campanha)` casava também `/campanha/login` (prefixo) — trocado por grace-wait de 5 s do destino exato antes do re-submit (cobre a navegação que caiu logo após o timeout do 1º wait; re-fill no dashboard seria erro falso).
3. **Prewarm de alvos de redirect + suggest** (a classe compile-frio não é só `page.goto`): o submit "Abrir demanda" redireciona para `/campanha/demandas/[slug]` — módulo fora da lista (audit de goto não vê redirects) → adicionado `e2e-prewarm`; e o suggest do overlay FAB POSTa `/campanha/home-search` — fora da lista POST → adicionado.
4. **Refator mínimo do spec `campaignMunicipalities` (evidência de execução)**: três testes falhavam deterministicamente em dev — todos da classe de abort do compile-frio no cliente:
- `:518` (B24): o botão de tendência só existe no estágio largo (B158) e o locator pegava a cópia mobile escondida → `ensureWideMunicipalityList` + escopo `municipalityContainer` + `.filter({ visible: true })` (padrão dos irmãos); o re-clique pós-reload era pré-hydration (classe P3-C) e o trigger **toggles** (clique cego por iteração fecha o popover aberto) → `waitForLoadState('networkidle')` pós-reload + clique só quando o popover está fechado, lendo `.first()` do slot (duplicação transitória do RSC).
- `:713` (demanda): o server action de criação compila frio no 1º POST e o reload aborta o submit → retry-único do submit em `toPass` (form preserva valores; duplicata inofensiva).
- `:1063` (FAB suggest): **root cause = paridade de seed** — o CI roda `pnpm db:seed:minimal` (pinna `priority: 'alta'` em salvador-ze-1/camacari) e o suggest do coordinator é vazio sem isso (DB de worktree provisionado sem o seed — gap do OPS28, em progresso; registrado como achado do OPS28, não reaberto aqui); blur+refocus re-dispara o suggest em `toPass` como defesa do stream truncado por compile de irmão.
5. **Prewarm de rotas do chat + PWA + popover faltante** (ERR_ABORTED residual de `:78` no goto `/editar` já prewarmado): `/campanha/api/ai-chat` + `ai-transcribe` (chat Sollinha montado em toda página autenticada — 1º hit no meio da suíte), `sw.js` + `manifest.webmanifest` (buscados em todo load) e `/campanha/municipios/next-steps` entraram no prewarm; com isso o aceite ×3 ficou estável.
### Evidência (2026-08-10, máquina compartilhada com 4–5 worktrees ativos, load 5–70)
- **Aceite — `campaignMunicipalities` 22/22 (arquivo atual, incl. B193 ×4 + OPS29) ×3 consecutivos em dev a 2 workers** (runs 16:28–16:39, load 5–17; incl. `:78` e `:518`; as 3 runs verdes anteriores com 17 testes foram absorvidas pelo rebase que trouxe B193/OPS29 do main — re-prova com o arquivo atual). Runs sob load 40–70 continuam caindo por starvation de CPU (renders RSC > 60 s) — classe fora do escopo (não é compile-frio); a evidência foi coletada nas janelas quietas.
- **P4-L — `campaignLeaderships` 3/3 em dev a 2 workers (load 4–10) + 1/1 com `E2E_PROD=1`** (build local em `.next/e2e`; 1.6 s/1.9 s). TECH-DEBT row 59 fechado com a evidência; TESTING.md caveat atualizado.
- **Setup**: prewarm completo (27 GETs + 16 POSTs) passando em dev e prod; retry-único absorveu aborts reais (socket hang up sob load).
### Ajustes do /simplify (3 revisores read-only, 2026-08-10 — 0 P1, P2/P3 aplicados)
- `/campanha/pessoas` (campaignPeople) entrou no prewarm GET — o audit por grep de goto tinha passado batido (audit refeito por grep completo).
- `prewarmGet` loga o status do 1º hit quando o retry salva (rastro do masking residual).
- Login: sub-budgets explícitos somando <60 s (15+5+30) — antes o 2º wait nunca completava dentro do test timeout e credencial errada morria de "Test timeout" genérico; retry agora bifurca pela URL real (dashboard → return; fora do login → throw com a URL; login → re-submit) — o re-submit cego contra página que saiu do login falhava com fill na dashboard.
- B24: locator reusa `visibleMunicipalityButton` (escapeRegExp + fronteira — nomes com prefixo compartilhado, 23/435) e `ensureWideMunicipalityList` re-rodado após o reload (padrão do irmão dobradinhas).
- `:713`: o retry cego do submit criaria duplicata que o UNIQUE do `campaignDemand.slug` refuta — o retry agora consulta por título e navega direto ao detail quando o 1º POST já criou (redirect perdido).
- `:1063`: `waitForResponse` com timeout curto (5 s) — sem ele o budget do toPass queimava no wait e o erro apontava a classe errada.
- **`docs/TECH-DEBT.md`** (row 59, P4-L): status `open — Pass 4…` → `closed <data> (evidência: spec B34 ×3 dev 2 workers + 1× E2E_PROD=1 verdes, <data>)` — só após a evidência coletada.
- **`docs/TESTING.md`** (linha 15): caveat "chip-cell spec currently fails deterministically under `pnpm dev`" vira referência ao fechamento (o ledger é a fonte; doc não pode contradizer o ledger fechado).
- **`docs/plans/hardening-e2e-dev-compile-frio-impl.md`**: este arquivo (commitado na entrega).
- **`docs/CHANGELOG-AGENTS.md`**: uma entrada curta no padrão das entregas.
- **Migration:** sem migration. **Access / Consent:** sem mudança (setup roda sem sessão; prewarm segue o padrão GET→redirect existente). **UI:** Impeccable A — N/A, infra de testes.
- **Model note:** Issue #586 não declara `model-local:` → mapeamento canônico aplicado (`composer-2.5` → `deepseek-v4-flash-high`) e registrado na Issue no fechamento; sessão = `deepseek-v4-flash` (pareia da classe — informa e segue).
### Audit de prewarm (grep de `page.goto` em `tests/e2e/*.spec.ts`)
**Já prewarmados (GET):** login, `/campanha`, quadro, municipios, territorios, `municipios/e2e-prewarm`, perfil, demandas, liderancas, conceitos, `acoes/atualizar-votos`, `convite/e2e-prewarm`, `/`.
**Faltando (vão para a lista GET):**
- `/campanha/municipios/e2e-prewarm/editar` — **a rota de edição do flake `:78`** (única rota `editar` navegada por specs; grep: 1 match — `campaignMunicipalities.e2e.spec.ts:107`).
- `/campanha/agenda` (campaignActivity ×2, campaignAgendaFeed ×2, campaignAgendaMobile).
- `/campanha/atividades/nova` (campaignActivity).
- `/campanha/demandas/nova` (campaignMunicipalities).
- `/campanha/contatos` (campaignBottomNav, campaignMunicipalities).
- `/campanha/atualizacoes` (campaignUpdatesMobile).
- `/campanha/acoes/atualizar-lideranca`, `/campanha/acoes/mudar-tendencia`, `/campanha/acoes/registrar-atualizacao` (campaignHomeActions — rotas de wizard com query param; módulo compila sem param).
- `/campanha/offline` (campaign-pwa — fora de `(app)`, sem gate).
**Fora de escopo (registrado):** rotas `/admin*` (projeto `admin` tem fase própria de compilação; fora da intenção); variantes de query são o mesmo módulo; server actions (login) **não** são prewarmáveis — o login server action só compila no primeiro POST real (é exatamente o que o retry do fixture cobre).
## Fases verificáveis
1. **Setup + fixture (tracer bullet)** — editar `setup.e2e.spec.ts` e `campaignE2EFixtures.ts`; rodar `pnpm exec playwright test --config=playwright.config.ts --project=setup` (prova que todo prewarm responde `ok()`; qualquer 500 → investigar, não dropar) + `gate:fast`.
2. **Evidência dev (aceite)** — `campaignMunicipalities.e2e.spec.ts` **×3 consecutivos** em dev a 2 workers (inclui `:78` e `:518`); `campaignLeaderships.e2e.spec.ts` **×3** em dev. Registrar load average junto da evidência.
3. **Evidência prod (P4-L)** — `pnpm build` local + `campaignLeaderships.e2e.spec.ts` **1× com `E2E_PROD=1`** (modo honesto do ledger).
4. **Ledger + docs** — fechar TECH-DEBT row 59, ajustar TESTING.md, entrada no CHANGELOG-AGENTS.md.
5. **Gates + push** — `pnpm gate:fast`, `pnpm format:check`, `knip`, `check:cycles`; `pnpm push` → PR Ready `--base main` + `Closes #586` + auto-merge + `gh pr checks --watch --required`.
## Rabbit holes / Não escopo (engenharia)
- `waitForLoadState('networkidle')` antes de todo goto — esconde flake por sorte; cortado na intenção.
- Trocar dev server por `pnpm start` no padrão local — decisão de fluxo; `E2E_PROD=1` já existe como modo honesto.
- Refatorar o spec `campaignMunicipalities` além do mínimo — fora de escopo da intenção.
- Prewarm de server actions (login) — tecnicamente inviável sem action-id; o retry cobre.
- Rotas `/admin*` — projeto próprio, fase própria.
## Riscos e mitigação
- **Prewarm devolve não-ok para alguma rota nova** (ex.: rota que quebra sem sessão): o run do setup falha e aponta a rota — investigar como sinal real, não dropar silenciosamente.
- **Retry mascara falha real de login:** impossível para credencial errada (falha duas vezes); o único caso teoricamente mascarado é falha transiente do servidor que se auto-resolve — aceitável e alinhado ("login é setup, não contrato").
- **×3 dev depende da carga da máquina:** registrar load average; em worktree dedicado (slot 30, porta 3130) o ambiente é isolado.
- **Double-submit do login se a 1ª navegação completou mas o wait estourou:** guarda de `page.url()` antes do re-submit.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (nenhuma asserção de produto tocada; app intacto)
- [ ] Invariantes AGENTS/engineering-standards (sem migration/access/Consent; fixtures test-infra)
- [ ] Testes de domínio previstos: gate:fast + e2e conforme evidência (×3 dev + ×1 prod)
- [ ] Self-score decision-quality: 5/5 (opções rejeitadas registradas; audit por grep; depth check: reusa mecanismo existente do setup e o fixture único de login)

View file

@ -542,10 +542,17 @@ test.describe('Municípios — jornadas por papel', () => {
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
const trendButton = page.getByRole('button', {
name: new RegExp(`^Editar tendência política em ${municipality.name}`),
})
// The trend control renders in both the desktop cell and the always-mounted
// mobile-card tree (B158); `visibleMunicipalityButton` scopes to the list
// container and the visible copy after the wide stage settles, the way the
// sibling journeys do.
const trendButton = visibleMunicipalityButton(
page,
`Editar tendência política em ${municipality.name}`,
)
await expect(trendButton).toBeVisible()
await trendButton.click()
const trendPopover = page.locator('[data-slot="popover-content"]')
await expect(trendPopover).toBeVisible()
@ -562,10 +569,23 @@ test.describe('Municípios — jornadas por papel', () => {
await page.keyboard.press('Escape')
await page.reload()
await trendButton.click()
// The reload restarts the RSC stream and the B158 stage: the filtered row
// re-renders in waves (each one moves the button, so a click keeps waiting
// for stability) and hydration lags SSR paint (a pre-hydration click is a
// silent no-op, P3-C class). Re-settle the wide stage first, then click
// only while the popover is closed — the trigger TOGGLES, so a blind
// click-per-iteration would close an open popover forever. The reopened
// popover proves the persisted save (server round trip), not just the
// optimistic chip.
await ensureWideMunicipalityList(page)
const reopened = page.locator('[data-slot="popover-content"]')
await expect(reopened.getByLabel('Tendência', { exact: true })).toHaveValue('favoravel')
await expect(reopened.getByLabel('Justificativa')).toHaveValue(note)
await expect(async () => {
if (!(await reopened.first().isVisible())) await trendButton.click()
await expect(reopened.getByLabel('Tendência', { exact: true })).toHaveValue('favoravel', {
timeout: 2_000,
})
await expect(reopened.getByLabel('Justificativa')).toHaveValue(note)
}).toPass({ timeout: 15_000 })
})
test('coordinator registers an adversary-signal update from the list freshness cell', async ({
@ -738,8 +758,29 @@ test.describe('Municípios — jornadas por papel', () => {
await page.goto(`${campaign.baseURL}/campanha/demandas/nova`)
await page.getByLabel('Município').selectOption({ label: municipality.name })
await page.getByLabel('O que você precisa?').fill(demandText)
await page.getByRole('button', { name: 'Abrir demanda' }).click()
await expect(campaignPageChrome(page, demandTitle)).toBeVisible()
// The create action is a server action — not prewarmable — and its first
// dev-mode invocation can be aborted by the compile full-page reload, which
// leaves the form filled but never navigates. On the retry: if the demand
// already exists (the POST landed but the redirect was lost — `slug` is
// unique, a second submit would fail the constraint), go straight to its
// detail; otherwise re-submit against the now-warm action.
await expect(async () => {
if (page.url().includes('/campanha/demandas/nova')) {
const existing = await campaign.payload.find({
collection: 'campaignDemand',
where: { title: { equals: demandTitle } },
depth: 0,
limit: 1,
pagination: false,
})
if (existing.docs.length > 0) {
await page.goto(`${campaign.baseURL}/campanha/demandas/${existing.docs[0].slug}`)
} else {
await page.getByRole('button', { name: 'Abrir demanda' }).click()
}
}
await expect(campaignPageChrome(page, demandTitle)).toBeVisible({ timeout: 10_000 })
}).toPass({ timeout: 30_000 })
await expect(page.getByRole('button', { name: 'Aprovar' })).toBeVisible()
await page.goto(`${campaign.baseURL}/campanha/demandas`)
@ -1493,18 +1534,26 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
await expect(registerSignal).toBeVisible()
const search = overlay.getByLabel('Buscar na campanha')
const suggestResponse = page.waitForResponse(
(resp) =>
resp.url().includes('/campanha/home-search') &&
resp.request().method() === 'POST' &&
resp.request().postDataJSON()?.mode === 'suggest',
)
await search.focus()
await suggestResponse
await expect(overlay.getByRole('link', { name: 'Registrar atualização' })).toBeHidden()
// Overlay scopes search suggest — list page has no E11 SuggestionsPanel.
await expect(overlay.getByRole('region', { name: 'Sugestões' })).toBeVisible()
// Dev-mode: a sibling test's cold compile can full-page-reload this client
// mid-suggest and truncate the response stream after headers (the setup
// prewarm cannot cover the suggest server work). Blur+refocus re-fires the
// suggest against the now-warm route.
await expect(async () => {
const suggestResponse = page.waitForResponse(
(resp) =>
resp.url().includes('/campanha/home-search') &&
resp.request().method() === 'POST' &&
resp.request().postDataJSON()?.mode === 'suggest',
{ timeout: 5_000 },
)
await search.blur()
await search.focus()
await suggestResponse
await expect(overlay.getByRole('link', { name: 'Registrar atualização' })).toBeHidden()
await expect(overlay.getByRole('region', { name: 'Sugestões' })).toBeVisible({
timeout: 3_000,
})
}).toPass({ timeout: 30_000 })
})
test('overlay search without curated suggestions shows the honest empty state (OPS29)', async ({

View file

@ -621,13 +621,43 @@ export const campaignFixture: CampaignE2EFixtureValue = async ({}, runFixture) =
login: async (page, identifier, password) => {
await page.context().clearCookies()
await page.goto(`${baseURL}/campanha/login`)
await page.getByLabel('E-mail ou celular').fill(identifier)
await page.getByLabel('Senha').fill(password)
// `exact`: the login screen also offers "Entrar com digital ou Face
// ID" (B40) whenever the device has a platform authenticator — which
// any spec adding a virtual one does.
await page.getByRole('button', { name: 'Entrar', exact: true }).click()
await page.waitForURL(`${baseURL}/campanha`)
// Dev-mode resilience (OPS30): Next dev compiles a route on its first
// hit, and that compile can full-page-reload a connected client —
// aborting the login POST before it lands (the mechanism documented
// in setup.e2e.spec.ts). A budget-only wait cannot recover an aborted
// submit, so on a navigation timeout we re-submit ONCE: the action is
// warm by then. The grace wait absorbs a navigation that landed just
// past the first wait, and the URL branch refuses a blind re-submit
// against a page that left the login screen. Sub-budgets sum below
// the 60 s test timeout, so a real credential failure surfaces as a
// crisp wait error, not a generic test timeout. A second failure
// surfaces — wrong credentials fail both attempts.
const dashboardURL = `${baseURL}/campanha`
const onDashboard = (url: string) => url === dashboardURL || url === `${dashboardURL}/`
const submitLogin = async () => {
await page.getByLabel('E-mail ou celular').fill(identifier)
await page.getByLabel('Senha').fill(password)
// `exact`: the login screen also offers "Entrar com digital ou Face
// ID" (B40) whenever the device has a platform authenticator — which
// any spec adding a virtual one does.
await page.getByRole('button', { name: 'Entrar', exact: true }).click()
}
await submitLogin()
try {
await page.waitForURL(dashboardURL, { timeout: 15_000 })
} catch {
try {
await page.waitForURL(dashboardURL, { timeout: 5_000 })
} catch {
const current = page.url()
if (onDashboard(current)) return
if (!current.startsWith(`${baseURL}/campanha/login`)) {
throw new Error(`Login retry aborted: page left the login screen (${current}).`)
}
await submitLogin()
await page.waitForURL(dashboardURL, { timeout: 30_000 })
}
}
},
payload: fixtures.payload,
sessionFor: async (context, user) => {

View file

@ -3,7 +3,26 @@ import { expect, test } from '@playwright/test'
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
test('prewarms shared Next route bundles sequentially', async ({ request }) => {
test.slow()
// 27 route modules compiled cold; measured ~3-6 min total under
// parallel-worktree load (load ~50, 2026-08-10: /campanha/contatos alone
// took 57 s), plus a one-shot retry per GET for the dev-server abort class
// below. On CI the production build answers warm in seconds, so the budget
// never bites there.
test.setTimeout(420_000)
// A cold compile can make Next dev abort an in-flight request (socket hang
// up, measured on the original list under load) — the route compiles anyway,
// so a single retry lands warm. The assertion runs on the retry only;
// a second failure is a real regression (broken/404 route).
const prewarmGet = async (path: string) => {
const first = await request.get(`${baseURL}${path}`).catch(() => undefined)
if (first?.ok()) return first
if (first) {
console.info(`[prewarm] first attempt ${path} answered ${first.status()} — retrying once`)
}
const retry = await request.get(`${baseURL}${path}`).catch(() => undefined)
expect(retry?.ok(), `Failed to prewarm ${path}`).toBe(true)
return retry
}
for (const path of [
'/campanha/login',
'/campanha',
@ -11,18 +30,36 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/municipios',
'/campanha/territorios',
'/campanha/municipios/e2e-prewarm',
'/campanha/contatos',
// The only edit route specs navigate (grep-verified 2026-08-10): compiling
// it cold mid-test aborted the :78 goto with ERR_ABORTED (dev full-reload).
'/campanha/municipios/e2e-prewarm/editar',
'/campanha/perfil',
'/campanha/demandas',
'/campanha/demandas/nova',
// The demand-create form redirects to /campanha/demandas/<slug>; the detail
// module is a goto-less redirect target, so it needs its own prewarm entry
// (same class as /editar: cold compile mid-test aborts the redirect).
'/campanha/demandas/e2e-prewarm',
'/campanha/liderancas',
'/campanha/contatos',
'/campanha/conceitos',
'/campanha/acoes/atualizar-votos',
'/campanha/acoes/atualizar-lideranca',
'/campanha/acoes/mudar-tendencia',
'/campanha/acoes/registrar-atualizacao',
'/campanha/agenda',
'/campanha/atividades/nova',
'/campanha/atualizacoes',
'/campanha/offline',
'/campanha/pessoas',
// PWA artifacts every authenticated page fetches on load — a cold compile
// here mid-suite full-page-reloads connected clients (ERR_ABORTED class).
'/campanha/manifest.webmanifest',
'/campanha/sw.js',
'/campanha/convite/e2e-prewarm',
'/',
]) {
const response = await request.get(`${baseURL}${path}`)
expect(response.ok(), `Failed to prewarm ${path}`).toBe(true)
await prewarmGet(path)
}
// POST-only API route handlers (auto-save popovers): Next dev compiles a
@ -38,12 +75,18 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/municipios/pledge-declared-votes',
'/campanha/municipios/pledge-estimated-votes',
'/campanha/municipios/political-trend',
'/campanha/municipios/next-steps',
'/campanha/liderancas/support-status',
'/campanha/home-search',
// The Sollinha chat mounts on every authenticated page: its transport and
// the PWA registration hit these on first load/session-restore — cold
// compiles mid-suite are the ERR_ABORTED class (measured on :78 /editar).
'/campanha/api/ai-chat',
'/campanha/api/ai-transcribe',
'/campanha/webauthn/login-options',
'/campanha/webauthn/login',
'/campanha/webauthn/register-options',
'/campanha/webauthn/register',
'/campanha/home-search',
]) {
await request.post(`${baseURL}${path}`, { data: {} }).catch(() => undefined)
}