fix(B32-B34): estabiliza e2e de lideranças — settle/poll, colapso por conteúdo e waiter diagnóstico (Closes #1036)

This commit is contained in:
Francisco Solla 2026-09-15 18:27:17 -03:00
parent d46360d1dc
commit e72677bee7
6 changed files with 208 additions and 21 deletions

View file

@ -0,0 +1 @@
**B32/B34 — Raiz dos testes de lideranças que quebraram o verify (flake sob carga, não regressão) (2026-09-15):** test-only, zero mudança de produção. O diagnóstico com os logs dos 4 runs (`34984805702`, `34991826170`, `34997300602`, `35008333471`) fechou como **flake sob carga de 4 workers**: nenhum arquivo de liderança/célula/action mudou entre os SHAs que falharam e o HEAD, e a trilha C165/C165-F1 tem zero acoplamento com `leadership`. Mecanismos: (1) B32 esperava o botão da célula por 60 s porque o row streama com o chunk RSC e o spec não tinha gate de settle — o clique nu consumia o timeout inteiro do teste; (2) B34 usava o gate inline `div[id^="S:"]` (o helper `waitForStreamSettled` do OPS83 é o owner e o JSDoc dele já mandava parear com poll focado) e o `expectPostResponse` filtra `response.ok()`, então um 400 da rota de municípios virava 60 s de espera cega com só "400 Bad Request" no console. (3) a célula de municípios clampa em 3 linhas e, com **dois** chips + nomes largos, o espaço reservado do input de busca derruba o `fitting` a 0 — todos os chips saem do DOM atrás de "Ver mais…" (snapshot de falha local confirma: célula só com "Ver mais…"), e o spec afirmava visibilidade de chip sem expandir. Fix spec-only em `tests/e2e/campaignLeaderships.e2e.spec.ts`: `waitForStreamSettled` + orçamento explícito de 30 s na presença do controle (B32), poll focado do chip inicial antes do `href` (receita do próprio helper), `expandCollapsedChips()` clicando "Ver mais…" (nunca "Ver menos") antes de cada asserção de chip, e `expectPostResponse` ganhou opt-in `throwOnNonOk` no fixture owner (`campaignE2EFixtures.ts`) — resolve no primeiro POST da rota e lança `POST <rota> → <status>: <body>` em recusa, sem alterar os 19 usos existentes. Prova local: modo prod (`E2E_PROD=1`, o modo do CI) 10/10 verdes com 4 workers e `--repeat-each=5`; gate:fast completo (lint + typecheck + 3268 unit). O `verify` full pós-merge é a prova final (4 workers). Observação operacional registrada no plano: os runs `a1649613`/`9e00de36`/`c2ee60dd` têm `verify: skipped` (preflight pula com outro deploy na lane) e não são evidência de estabilidade — o último verify que de fato executou (`36d9abe8`) voltou a ver B32/B34 flaky. Plano: `docs/plans/b32-b34-liderancas-estabilidade-verify-impl.md`.

View file

@ -0,0 +1,77 @@
# B32/B34 — Raiz dos testes de lideranças que quebraram o verify 3 runs seguidos (regressão ou flake sob carga) — Plano de Implementação
Status: pronto
Atualizado em: 2026-09-15
Issue: #1036
Priority: P1
Impeccable: N/A — sem UI
Rascunho UI: N/A
Appetite: ~1–2 dias eng (diagnóstico + fix + prova verde)
Responsável: —
## Diagnóstico (revisado com logs)
**Veredito: flake sob carga de 4 workers — não é regressão de código.** A trilha C165/C165-F1 é inocente: nenhum arquivo de liderança/célula/action mudou entre os SHAs que falharam e o HEAD.
Evidência (logs dos jobs `104476711539`, `104458195479`, `104434116739` + run `35008333471`):
1. **B32 (`campaignLeaderships.e2e.spec.ts:15`)** — `Test timeout of 60000ms exceeded` no `locator.click` de "Editar status de apoio" (`:35:72`), sempre os 60 s inteiros do teste, em runs 2 e 3 (3/3 tentativas). O call log diz `waiting for getByRole(...)`: o controle da célula é filho de um boundary RSC — enquanto o chunk não commita, o botão existe apenas na cópia oculta `div[id^="S:"]` do stream (a mesma classe OPS83). O spec não tem gate de settle nenhum antes do clique e o clique consome o orçamento inteiro do teste.
2. **B34 (`campaignLeaderships.e2e.spec.ts:59`)** — duas falhas distintas:
- `toHaveAttribute`/`toBeVisible` falhando na presença do chip (runs 1 e 3: `:91`, run 2: `:133`) **depois** do gate de settle inline — o gate genérico `div[id^="S:"]` pode resolver antes do chunk começar a streamar; a receita documentada do próprio helper (`waitForStreamSettled`, OPS83/E2E-DEBT-S-GATE) manda parear com um poll focado no elemento.
- `page.waitForResponse: Test timeout of 60000ms exceeded` no primeiro add (run 2, retry #1) com `400 Bad Request` no console e um `NotFound: Não Encontrado` no `payload.findByID` da liderança (runs 1 e 3, retry #1). O `expectPostResponse` filtra `response.ok()`, então um 400 vira 60 s de espera cega — a mensagem do corpo da rota (floor/cap/scope/staff/leadership inexistente) nunca aparece no log.
- **Colapso por conteúdo (reproduzido localmente sob carga):** a célula de municípios clampa em 3 linhas e esconde os chips atrás de "Ver mais…". Com **dois** chips, o loop de medição desconta o espaço reservado do input de busca (`min-w-32`) + toggle; com nomes largos — e o allocator entrega nomes arbitrários por run — o `fitting` chega a **0** e TODOS os chips saem do DOM (snapshot de falha: célula só com "Ver mais…" e status "Municípios salvos."). O spec afirmava visibilidade de chip sem expandir, então o timeout de 30 s era certo quando o par de nomes não cabia. É a mesma classe "flake sob carga" que os vizinhos, mas determinística por conteúdo.
3. **Não é "último verify verde":** o run `35002372682` (`13aeb9bb`, 17:36) rodou o verify full e passou — é o mesmo HEAD verde que a Issue já registrava como anti-recorrência. Os runs seguintes (`a1649613`, `9e00de36`, `c2ee60dd`) têm `verify: skipped` (preflight pula quando outro deploy está queued/in_progress) e **não** são prova de estabilidade. O último verify que de fato executou (`35008333471`, `36d9abe8`, 18:34) **falhou**, e B32/B34 estavam de novo no conjunto flaky (B34 ✘→✘→✓ retry #2; B32 ✘→✓ retry #1) — o problema segue vivo.
4. **Vizinhos** (`campaignAiChatResize:44`, `campaignColumnPicker:21`, `campaignMunicipalities:659`, `campaignActivity:525`, `campaignSavedFilters:35`) são a mesma classe de latência sob 4 workers (postmortem 2026-09-12); não são alvo.
## Decisão
**Hardening do spec + diagnóstico no waiter** — usar os helpers/recipe já abençoados pelo OPS83 em vez de gates inline, dar orçamentos explícitos aos pontos que hoje consomem o timeout inteiro do teste, e fazer o waiter do POST falhar rápido com o corpo da resposta quando a rota recusa. Zero mudança de produção.
### Alternativas rejeitadas
- **Marcar como flaky / aumentar retry:** `verify` verde por tolerância esconde a regressão viva — rabbit hole explícito da Issue.
- **Mudar código de produção (células/actions/access):** sem evidência de que a UI seja a causa; o diagnóstico mostra latência de stream/RSC e orçamento de teste, não comportamento.
- **Reabrir a cast rotativa do #882:** escopo distinto; B32/B34 são persistentes, não rotativos.
- **Mudar a semântica global de `expectPostResponse` (resolver em qualquer POST):** mudaria o contrato compartilhado por 19 usos em 5 specs; adotou-se um opt-in `{ throwOnNonOk: true }` no owner, com o default byte-idêntico.
## Mudanças
`tests/e2e/campaignLeaderships.e2e.spec.ts` (único arquivo de teste):
- **B32:** `waitForStreamSettled(page)` depois do chrome, `expect(statusButton).toBeVisible({ timeout: 30_000 })` e só então o clique — o botão deixa de consumir os 60 s do teste quando o row streama tarde.
- **B34:** gate inline `page.waitForFunction(div[id^="S:"])` → `waitForStreamSettled(page)` (owner do concern, OPS83/E2E-DEBT-S-GATE); poll focado `expect.poll(count).toBeGreaterThan(0)` no chip inicial antes do `toHaveAttribute` (receita documentada do helper); waiter de POST via `expectPostResponse(..., { throwOnNonOk: true })` (owner), sem twin local.
- **B34 colapso por conteúdo:** `expandCollapsedChips()` — espera a medição do clamp assentar e clica "Ver mais…" quando ele está no estado colapsado (nunca "Ver menos"), antes de cada asserção de chip (inicial, pós-add, pós-reload). É o caminho do usuário; sem ele, nomes largos deixam o chip fora do DOM.
`tests/e2e/fixtures/campaignE2EFixtures.ts` (owner do helper):
- `expectPostResponse` ganha `{ throwOnNonOk?: boolean }` (default `false`, os 19 usos existentes intocados): resolve no primeiro POST da rota e lança `POST <rota> → <status>: <body>` quando a resposta não é ok — em vez de pendurar o teste até o timeout com um "400" cego.
`docs/changelog/2026-09-15-b32-b34.md` (registro obrigatório da entrega, OPS85) e este impl plan.
## Prova
- `pnpm gate:fast` (lint + typecheck + unit) verde.
- E2E dos specs alterados em **modo prod** (`E2E_PROD=1`, o modo do CI — o dev server local emite um warning React de key dev-only que o `e2eFailureGuard` trata como erro e que não existe no build de produção), com repetição para estresse.
- `verify` full (4 workers) no PR.
## Riscos
1. **Orçamentos explícitos mascararem regressão real:** os budgets são de presença (30 s) e o teste continua falhando rápido quando o elemento some — mitigado pelo waiter que lança a mensagem da rota.
2. **400/NotFound residual:** o corpo da resposta passará a aparecer no log; se a causa for a colisão cross-run (purge-on-claim numa linha viva), o diagnóstico fica instrumentado para a próxima ocorrência. Não se mexe no contrato do allocator aqui.
3. **Flake dos vizinhos:** fora de escopo (evidência, não alvo).
## Fora de escopo
- Contrato de deploy (`deploy.yml`, jobs, runners).
- Mudança de UI/comportamento visível da lista.
- Reescrita ampla dos specs B32/B34 ou das outras superfícies da cast.
- Semântica global de `expectPostResponse` (19 usos).
## Referências
- Issue #1036 · Run `35002372682` (verify verde em `13aeb9bb`) · Run `35008333471` (último verify executado, falhou em `36d9abe8`)
- Jobs de log: `104476711539`, `104458195479`, `104434116739`, `104513700572`
- `tests/e2e/campaignLeaderships.e2e.spec.ts` (B32 `:15`, B34 `:59`)
- `tests/e2e/fixtures/campaignE2EFixtures.ts` (`waitForStreamSettled` `:632`, `expectPostResponse` `:554`)
- `docs/plans/autosave-status-lista-liderancas.md` (B32) · `docs/plans/chips-municipios-lista-liderancas.md` (B34)
- Postmortem 2026-09-12 (classe de latência sob 4 workers)

View file

@ -1,9 +1,9 @@
# B32/B34 — Raiz dos testes de lideranças que quebraram o verify 3 runs seguidos (regressão ou flake sob carga)
Status: rascunho
Status: entregue (2026-09-15) — impl plan: `docs/plans/b32-b34-liderancas-estabilidade-verify-impl.md`
Atualizado em: 2026-09-15
Issue: #1036 (blocked via --plan — promover com agent:ready após merge em main)
Priority: P1 (proposto — validar no gate; alternativa P2 abaixo)
Issue: #1036
Priority: P1 (validado no gate)
Impeccable: A — N/A sem UI
Rascunho UI: N/A — sem UI
Appetite: ~1–2 dias eng (diagnóstico + fix + prova verde)
@ -72,7 +72,7 @@ Quero saber por que a lista de lideranças quebrou o deploy três vezes seguidas
## Referências
- GitHub Issue #1032 (reservado)
- GitHub Issue #1036
- Rascunho UI (gate): N/A
- `tests/e2e/campaignLeaderships.e2e.spec.ts` (specs `:15` B32 e `:59` B34)
- `docs/plans/autosave-status-lista-liderancas.md` (B32, precedente entregue)

View file

@ -0,0 +1,55 @@
# UX pós-B32/B34 — chip oculto pelo clamp e key warning do head (débito da entrega #1036)
Status: pronto
Atualizado em: 2026-09-15
Issue: (registrada via agent:register — blocked até este plano em main)
Priority: P3
Impeccable: B — encaixe em lista existente, sem fluxo novo
Rascunho UI: N/A — ajuste de medição + fix de key, sem tela nova
Appetite: ~0,5 dia eng (duas fases curtas)
Responsável: —
## Origem
Débito capturado na entrega da Issue #1036 (B32/B34 — flake dos testes de lideranças sob 4 workers). Durante o diagnóstico, dois achados de UI/test-infra ficaram **fora do escopo** daquela Issue (spec-only) e entram aqui por triage do `capture-review-debts`.
## F1 — `RelationChipCell` pode esconder TODOS os chips atrás de "Ver mais…" (score 3)
**Evidência (2026-09-15):** snapshot de falha do e2e `campaignLeaderships.e2e.spec.ts` (B34) com a célula de municípios exibindo **apenas** "Ver mais…" e o status "Municípios salvos." — os chips não estavam no DOM. Reproduzido sob carga de 4 workers e determinístico por conteúdo (nomes de município que o allocator entregou ao run).
**Mecanismo (medido no código):** o clamp de 3 linhas (`RelationChipCell.tsx:459-528`) desconta no `trailingWidth` o toggle + a largura mínima do input de busca (`min-w-32`, ~128px). Com **dois** chips, o loop `while (fitting > 0 && fitting < chipElements.length)` pode derrubar `fitting` a **0** quando o primeiro chip não cabe ao lado do espaço reservado — e `visibleChips` vira uma fatia vazia. A célula fica opaca: nenhum vínculo visível, só o toggle.
**Direção:** piso de 1 chip enquanto `chips.length > 0` (o último chip sempre visível, a 3ª linha passa a rolar/clipar em vez de sumir) + pin unitário da medição pura (extrair o cálculo de `fitting`/`trailingWidth` para função testável, se ainda não for). Se a geometria não permitir o piso sem quebrar a reserva do input, a alternativa é o toggle virar chip "+N" (o prop `overflowToggleLabel` já existe) — decidir no shape.
**Rabbit holes:** não redesenhar o clamp nem o shell da célula; não tocar nas 4 listas consumidoras (`liderancas`, `pessoas`, `municipios`, `dobradinhas`) além do comportamento compartilhado; o e2e do B34 já contorna com `expandCollapsedChips()` — a Issue é sobre o comportamento de UI, não sobre o teste.
## F2 — Warning React "unique key" em `CampaignSortableHead` (score 3)
**Evidência:** `e2eFailureGuard` falha todo e2e de `/campanha/liderancas` no dev server local com `console.error: Each child in a list should have a unique "key" prop. Check the render method of 'CampaignSortableHead'. It was passed a child from 'LeadershipSortableHead'.` Em produção/CI o warning não existe (React remove), mas o e2e local em dev mode fica vermelho e mascara flakes reais (foi por isso que a validação da #1036 exigiu `E2E_PROD=1`).
**Direção:** achar a lista sem key no caminho `LeadershipSortableHead` → `CampaignSortableHead` (provavelmente um array em `children`/`filter`/`wrapSortControl`) e corrigir a key. É fix de 1 linha + o e2e local em dev volta a ser confiável sem workaround.
**Rabbit holes:** não refatorar os heads; não silenciar o guard (a regra de `console.error` é intencional).
## Já resolvido no simplify/diagnóstico (não reabrir)
- Waiter de POST twin → `expectPostResponse(..., { throwOnNonOk: true })` no owner (`campaignE2EFixtures.ts`).
- `toHaveCount(1)` → poll documentado; comentários enganosos corrigidos; changelog da entrega criado.
- Contorno no e2e B34 (`expandCollapsedChips`) — o débito é o comportamento de UI, não o teste.
## Explicitamente fora
- Contrato do allocator de municípios / purge-on-claim (defer com gatilho na #1036: a próxima ocorrência do 400 imprime `POST <rota> → <status>: <body>`; se apontar colisão cross-run, vira `kind:defect` P2 com piso 4).
- Flakes vizinhos da cast sob 4 workers (`campaignMunicipalities:1567`/`:659`, `campaignContacts:28`/`:171`) — absorvidos pela Issue #882 (dona da família).
- Reescrita dos specs B32/B34 ou de outras superfícies da cast.
## Referências
- Issue #1036 · `docs/plans/b32-b34-liderancas-estabilidade-verify-impl.md` (diagnóstico com a evidência)
- `src/components/campaign/shared/RelationChipCell.tsx:459-528` (medição do clamp), `:788-815` (toggle)
- `src/components/campaign/shared/CampaignSortableHead.tsx`, `src/components/campaign/leadership/LeadershipSortableHead.tsx`
- `tests/e2e/fixtures/e2eTest.ts:75-95` (guard de `console.error`)
## Decision quality
Score 4/5 — evidência direta (snapshot + leitura do código), appetite curto, fases independentes e rabbit holes cortados; a única incerteza é a geometria do piso de 1 chip, resolvida no shape da F1.

View file

@ -3,6 +3,7 @@ import {
expect,
expectPostResponse,
test,
waitForStreamSettled,
} from './fixtures/campaignE2EFixtures.js'
/**
@ -31,8 +32,16 @@ test.describe('campaign leaderships list', () => {
await campaign.login(page, coordinator.email!, password)
await page.goto(`${campaign.baseURL}/campanha/liderancas?q=${encodeURIComponent(contactName)}`)
await expect(campaignPageChrome(page, 'Lideranças')).toBeVisible()
// The row streams with the list's RSC chunk; under 4-worker load the cell
// control can land after the shell commits (B32 flaked on the click under
// load: the button was still a hidden `S:` shell copy). Settle the stream,
// then wait for the button with an explicit budget — the click alone used
// to eat the whole 60 s test timeout.
await waitForStreamSettled(page)
const statusButton = page.getByRole('button', { name: 'Editar status de apoio' })
await expect(statusButton).toBeVisible({ timeout: 30_000 })
await page.getByRole('button', { name: 'Editar status de apoio' }).click()
await statusButton.click()
const statusPopover = page.locator('[data-slot="popover-content"]')
await expect(statusPopover).toBeVisible()
await expect(statusPopover.getByRole('button', { name: 'Salvar' })).toHaveCount(0)
@ -40,9 +49,10 @@ test.describe('campaign leaderships list', () => {
// Auto-save (150 ms debounce): wait for the actual POST response — the
// badge text updates optimistically before the request even lands, so a
// text-only assertion here would race the reload below against a save
// that hasn't reached the database yet.
// that hasn't reached the database yet. `throwOnNonOk` so a refused write
// reports the route's message instead of hanging the test.
await Promise.all([
expectPostResponse(page, '/campanha/liderancas/support-status'),
expectPostResponse(page, '/campanha/liderancas/support-status', { throwOnNonOk: true }),
statusPopover.getByLabel('Status de apoio', { exact: true }).selectOption('engajado'),
])
await expect(page.getByRole('button', { name: 'Editar status de apoio' })).toContainText(
@ -84,22 +94,45 @@ test.describe('campaign leaderships list', () => {
// The cell chips stream with the list's RSC chunk; under 4-worker load the
// chip link can land after `networkidle` settles (OPS83 run #16: B34
// flaked on the initial chip). Let the stream commit before asserting.
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
await waitForStreamSettled(page)
// The chip cell clamps to three rows and hides the rest behind "Ver mais…"
// — and because the allocator hands this run arbitrary municipality names,
// whether two chips fit with the search box's reserved width is
// CONTENT-dependent: with wide names the cell can collapse with every chip
// out of the DOM (B34 failed that way under load). Expand exactly like the
// user would before asserting on a chip.
const expandCollapsedChips = async () => {
const toggle = page.locator('[data-relation-toggle]')
// The toggle mounts invisible while the clamp measures, so settle first.
await expect
.poll(async () => (await toggle.count()) === 0 || (await toggle.isVisible()))
.toBe(true)
// Only the collapsed label expands; once expanded the same control reads
// "Ver menos" and must not be clicked again.
const collapsedToggle = page.locator('[data-relation-toggle]', { hasText: 'Ver mais' })
if (await collapsedToggle.isVisible()) await collapsedToggle.click()
}
// OPS83's own recipe: the generic settle gate alone can resolve before the
// chunk starts streaming, so a focused poll proves the chip exists before
// asserting on it (B34 flaked here at 4 workers).
await expandCollapsedChips()
const linkedChip = page.getByRole('link', { name: linked.name, exact: true })
await expect.poll(() => linkedChip.count(), { timeout: 30_000 }).toBeGreaterThan(0)
// The chip is still a link to the município — editing did not cost the
// navigation the column had before.
await expect(page.getByRole('link', { name: linked.name, exact: true })).toHaveAttribute(
'href',
`/campanha/municipios/${linked.slug}`,
)
await expect(linkedChip).toHaveAttribute('href', `/campanha/municipios/${linked.slug}`)
// Floor of one: the only chip cannot be removed.
await expect(
page.getByRole('button', { name: new RegExp(`não é possível remover ${linked.name}$`) }),
).toBeDisabled()
// The chip cell posts JSON to its mutation route — the optimistic chip
// renders before the write lands.
const persisted = () => expectPostResponse(page, '/campanha/liderancas/municipalities')
// renders before the write lands. `throwOnNonOk`: the 400 seen in the
// verify runs must surface the route's message, not a blind 60 s wait.
const persisted = () =>
expectPostResponse(page, '/campanha/liderancas/municipalities', { throwOnNonOk: true })
const search = page.getByRole('combobox', {
name: 'Buscar município, território de identidade ou zona eleitoral',
@ -111,6 +144,7 @@ test.describe('campaign leaderships list', () => {
.first()
await expect(suggestion).toBeVisible()
await Promise.all([persisted(), suggestion.click()])
await expandCollapsedChips()
await expect(page.getByRole('link', { name: added.name, exact: true })).toBeVisible()
// No "Salvar" anywhere in the cell: the write already happened.
@ -127,6 +161,7 @@ test.describe('campaign leaderships list', () => {
// Whatever the catalog ranks first — the query may prefix-match siblings.
const keyboardAdded = (await firstOption.innerText()).split('\n')[0]!.trim()
await Promise.all([persisted(), search.press('Enter')])
await expandCollapsedChips()
// 30 s: the POST landed (persisted above), so this is purely the optimistic
// chip render + RSC refresh — under 2-worker load with a cold dev compile
// that round-trip has measured past the 10 s expect budget (P3-C).
@ -141,6 +176,8 @@ test.describe('campaign leaderships list', () => {
await expect(page.getByRole('link', { name: linked.name, exact: true })).toHaveCount(0)
await page.reload()
await waitForStreamSettled(page)
await expandCollapsedChips()
await expect(page.getByRole('link', { name: added.name, exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: keyboardAdded, exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: linked.name, exact: true })).toHaveCount(0)

View file

@ -550,14 +550,31 @@ export class CampaignE2EOwnership {
* Every cell in the B32+ family paints optimistically, so asserting on the text
* proves nothing about the database — a `page.reload()` right after would race
* the POST. Pair this with the interaction inside one `Promise.all`.
*
* `throwOnNonOk` is for the specs whose route can refuse (400): the default
* filters on `response.ok()`, so a refusal would hang until the test timeout
* with only a bare "400" in the console. Opted in, the first matching POST
* resolves and a non-ok answer throws with the route's own message, making the
* failure diagnosable from the report alone.
*/
export const expectPostResponse = (page: Page, urlFragment: string) =>
page.waitForResponse(
(response) =>
response.url().includes(urlFragment) &&
response.request().method() === 'POST' &&
response.ok(),
)
export const expectPostResponse = (
page: Page,
urlFragment: string,
{ throwOnNonOk = false }: { throwOnNonOk?: boolean } = {},
) =>
page
.waitForResponse(
(response) =>
response.url().includes(urlFragment) &&
response.request().method() === 'POST' &&
(throwOnNonOk || response.ok()),
)
.then(async (response) => {
if (throwOnNonOk && !response.ok()) {
throw new Error(`POST ${urlFragment} → ${response.status()}: ${await response.text()}`)
}
return response
})
/**
* Checks a Radix Checkbox only once the page has hydrated. A Radix checkbox is