test(e2e): OPS87 — migrar asserções de servidor para HTTP sem browser (Concepts + PermissionProfile + People) + medir −65% (Closes #833)

This commit is contained in:
Francisco Solla 2026-08-24 15:21:10 -03:00
parent 3d4ddc08e3
commit f1af316f71
12 changed files with 908 additions and 395 deletions

View file

@ -0,0 +1 @@
**OPS87 — Pirâmide de verificação: migrar asserções de servidor dos e2e para o paradigma HTTP sem browser (escalar o OPS35) + medir (2026-08-24):** lote 1 da pirâmide migra Concepts + PermissionProfile + People para o modo HTTP do OPS35 — mesma cobertura, fração do tempo, menos interação client = menos classes de flake; HomeActions fica classificado para o lote 2 (fatia servidor fina + custo de separação alto; impl plan Decisão 1). **Novos specs** `campaignConceptsHttp` (2 testes), `campaignPermissionProfileHttp` (6 — o spec browser foi **deletado**, 6/6 eram server), `campaignPeopleHttp` (8) reusam `campaignHttpTest`/`campaignFixture` (auth real via REST login + cookie `campaign-token`, nenhuma fixture nova); os specs browser foram reduzidos à fatia client (Concepts 2→1 teste, People 12→6: os 2 mistos viraram "omnibox" e "C117 interação", os 4 client intocados C125/C131/C128×2) com cabeçalhos espelho twin. **Auditoria 1:1:** o HTTP herda os nomes originais dos testes migrados (git log/-g provam a migração); nada removido sem equivalente — 20 testes originais → **13 migrados 1:1** (1 Concepts + 6 PermissionProfile + 6 People) **+ 3 divididos** em fatia servidor (HTTP, nome original) + fatia client (browser, nome novo: Concepts tooltip/popover, People omnibox, C117 interação) **+ 4 client intocados** (C125/C131/C128×2); os 16 testes HTTP = 13 herdados + 3 fatias servidor; guard de console.error/pageerror continua exclusivo de browser. **Medição (aceite), prod build, 2 workers, `scripts/measure-e2e-family.mjs` comitado (reporter JSON, nunca estimativa):** baseline pré 54,7s/20t (Concepts 8,1s · PermissionProfile 14,3s · People 32,3s; 1ª rodada poluída por load pós-build deu 98,6s — registrada, comparativo usa a rodada estabilizada); pós 18,9s = **−65%**: Concepts 8,1→3,8s (−53%, browser 2,5s + HTTP 1,3s), PermissionProfile 14,3→1,6s (−89%, só HTTP), People 32,3→13,5s (−58%, browser 11,3s + HTTP 2,2s); teste HTTP custa 0,2–1,0s contra 2–14s de browser (10–50× por teste). **Infra:** `rendered()` (strip `<!-- -->` do streaming React) extraído do Territories para o fixture base; `e2e-affected-manifest.mjs` ganhou os specs novos + entrada própria do domínio C142 (advisor/quick-action/shell + rotas de escrita) — o classificador OPS86 seleciona os HTTP no CI; sem mudança de testMatch/config (OPS88 é item separado). Validado em dev e prod build (CI=1 E2E_PROD=1, 27 testes afetados verdes em 19,5s). Closes #833.

View file

@ -0,0 +1,173 @@
# Impl: OPS87 — Pirâmide de verificação: migrar asserções de servidor dos e2e pesados para o paradigma HTTP sem browser (escalar o OPS35) + medir
Status: em execução
Atualizado em: 2026-08-24
Issue: #833
Intenção: docs/plans/ops87-piramide-e2e-http-sem-browser.md
Appetite restante: ~2 dias herdado da intenção; lote 1 cortado para 3 famílias (ver Decisão 1) — o que sobrar é gatilho, não promessa.
## Leitura da intenção
- **Outcome:** cada família migrada tem o ganho registrado — tempo browser vs tempo HTTP, por spec migrado, **medido de verdade** (nunca estimativa); a cobertura não diminui (nada removido sem equivalente, asserções migram 1:1); a suíte fica mais estável nas famílias migradas; a migração anda em lotes e o deploy não fica bloqueado no meio de um lote.
- **O que NÃO negociar:** medição por família é **condição de aceite**, não cortesia; auth é real (mesma sessão/cookie `campaign-token`) sem duplicar lógica de login; fixtures e ownership existentes são reutilizados (`campaignFixture`, `campaignRequest`, `claimMunicipality`, `createCampaignUser`, `createStaffLeadership`, `mintCampaignSession`); RBAC continua assegurado no conteúdo renderizado; o guard de falha client (`console.error`/`pageerror`) permanece exclusivo de browser.
- **O que reavaliar:** a intenção recomenda começar por (concepts, permission-profile, people, home-actions). A exploração mostra que HomeActions tem a fatia servidor mais fina e o maior custo de separação (5 testes mistos com interação client entrelaçada) — o lote 1 deste impl plan corta HomeActions para a Fase 5 (gatilho) e documenta o mapeamento dela para o lote 2. Não é reescrever a intenção: é a regra dela própria ("um lote por vez", "o appetite manda").
## Abordagem recomendada
```mermaid
flowchart LR
A[Baseline: rodada prod dos 3 specs originais] --> B[rendered no fixture base + refactor Territories]
B --> C[Concepts: spec HTTP novo + browser reduzido]
C --> D[PermissionProfile: spec HTTP novo + spec browser deletado]
D --> E[People: spec HTTP novo + browser reduzido]
E --> F[Medição pós + tabela no changelog + manifest]
F --> G[Gates: gate:fast + prod + e2e:affected]
G --> H{Gatilho lote 2: sobrou ≥4h?}
H -- sim --> I[HomeActions fatia servidor, dentro do item]
H -- não --> J[HomeActions documentado como lote 2 / item novo]
```
**Opções consideradas:** A | B (detalhadas na Decisão 1)
**Recomendação:** B — lote 1 = **Concepts + PermissionProfile + People + medição**; HomeActions fica para lote 2 com gatilho. Porque: são as 3 famílias com fatia servidor dominante e migração 1:1 barata (12 dos 15 testes migrados sem nenhuma separação client/server), o que valida a medição com risco mínimo — exatamente o critério da intenção ("colhe o ganho cedo e valida a medição antes do lote grande"). Com números: Concepts ~0,5–1 h, PermissionProfile ~1–1,5 h, People ~3–4 h, medição (script + baselines + tabela) ~2–3 h, gates/PR ~1 h → total ~7,5–10,5 h ≈ 1–1,5 dias, dentro do appetite com margem. HomeActions ~4–6 h (separar 5 testes mistos com focus/blur, geometry, toasts, hydration) somada às outras **estoura** o appetite (~11,5–16,5 h ≈ 1,5–2,5 dias) — e é a família com o pior retorno do lote (fatia servidor de ~9 dos 18 testes, e só 4 deles 100% server).
**Rejeitadas:** A (4 famílias no lote 1) porque estoura o appetite e viola "deploy não bloqueado no meio de lote" — o lote 2 teria que ser abandonado pela metade se o tempo faltasse; B é o corte honesto com a regra "lote que não couber vira item novo" da própria intenção.
### Componentes / mudanças
- **`rendered(html)`** (`tests/e2e/fixtures/campaignHttpTest.ts`): export novo no fixture base — strip de `<!-- -->` (separadores de texto-node do streaming React são transporte, não conteúdo; padrão do Territories). Rejeitada: manter o helper local em cada spec (3+ call sites já no lote 1; o owner natural é o base).
- **`campaignTerritoriesHttp.e2e.spec.ts`**: refatorado para importar `rendered` do base (remover a cópia local). Sem mudança de asserção — o spec não é tocado além disso.
- **`campaignConceptsHttp.e2e.spec.ts`** (novo): 2 testes — "staff reads the concepts page and reaches it from the goal-account card" (fatia servidor, nome herdado) e "leader cannot open the concepts page" (1:1).
- **`campaignConcepts.e2e.spec.ts`** (reduzido): 1 teste fica — "staff reaches the concepts page via the tooltip and the card popover" (a interação client do teste misto original; ver Decisão 5).
- **`campaignPermissionProfileHttp.e2e.spec.ts`** (novo): 6 testes com nomes originais (1:1).
- **`campaignPermissionProfile.e2e.spec.ts`** (deletado): 6/6 testes são server; o equivalente HTTP cobre 1:1.
- **`campaignPeopleHttp.e2e.spec.ts`** (novo): 8 testes — 6 server com nomes originais + 2 mistos (lista merged e C117) com fatia servidor herdando o nome original.
- **`campaignPeople.e2e.spec.ts`** (reduzido): 6 testes ficam — 2 reduzidos à interação client + 4 client puros (C125, C131, C128 ×2).
- **`scripts/lib/e2e-affected-manifest.mjs`**: entradas `campaignConcepts`/`campaignPeople` ganham os specs `Http`; o domínio de advisor/access ganha entrada própria com `campaignPermissionProfileHttp` (hoje o spec não está mapeado — lacuna que este item corrige para o classificador acordar os novos arquivos). O unit test que pina os nomes dos specs passa a exigir os arquivos novos — criar antes de atualizar o pin.
- **`scripts/measure-e2e-family.mjs`** (novo): medição reproduzível (Decisão 3).
- **Migration:** sem migration (nenhuma mudança de schema — os specs só leem via HTTP autenticado real).
- **Access / Consent:** N/A — nenhum write novo; auth real via `POST /api/campaignUser/login` + cookie `campaign-token` (mesmo contrato do OPS35).
- **UI:** N/A (Impeccable A).
### Dados → forma (se aplicável)
O "dado" do item é a **medição por família** (condição de aceite). Forma escolhida: tabela markdown no changelog do item (`docs/changelog/2026-08-24-ops87.md` via `pnpm changelog:build`), gerada por script comitado reproduzível — combina o registro permanente que o OPS35 deixou (docs/CHANGELOG-AGENTS.md:227) com reprodutibilidade. Rejeitadas: (1) só rodada manual anotada — não reproduzível e não audita o drift; (2) dashboard/CI job de comparação — variação de máquina no hosted runner torna o comparativo injusto e gate de tempo em CI é flake de medição (Decisão 3).
## Decisões de engenharia
### Decisão 1 — Escopo do lote 1
**Opções:** A) 4 famílias completas (concepts, permission-profile, people, home-actions), migrando só as fatias servidor. B) Concepts + PermissionProfile + People; HomeActions fica para lote 2 (gatilho na Fase 5).
**Recomendação:** B — ver números em "Abordagem recomendada". A fatia servidor de HomeActions é ~9 dos 18 testes (4 100% server + 5 mistos) com o custo de separação mais alto do lote (focus/blur retract, geometry de 3 colunas, toasts pós-save, clique pré-hydration — nenhum reutilizável em HTTP, exigindo equivalentes em texto). Migrar 3 famílias + medir cabe em ~1–1,5 dias com margem para gates; a 4ª estoura. E a regra da intenção é explícita: "um lote por vez, deploy não fica bloqueado no meio" — um lote 1 que estoura o appetite quebra essa regra no meio do caminho.
**Rejeitadas:** A porque estoura o appetite e deixa o lote sujeito a corte no meio; "por tamanho decrescente" e "pelas mais flaky" (da intenção) porque o primeiro lote precisa validar a **medição** com risco baixo, e as famílias 100% server são o menor risco.
### Decisão 2 — Modelo de arquivo
**Opções:** A) spec novo `campaign<Family>Http.e2e.spec.ts` + spec browser original REDUZIDO mantendo só as fatias client. B) spec novo HTTP + deletar os testes migrados do spec browser. C) renomear o spec existente para `.browser.` e criar o `.http.` (mexe no testMatch).
**Recomendação:** A com convenções explícitas que absorvem o melhor de B:
1. **Spec HTTP herda o NOME ORIGINAL do teste migrado.** Para testes 100% server, o nome idêntico nos dois pontos do diff é a auditoria 1:1 — `git log`/`-g` provam a migração sem tabela externa. Testes 100% server **somem** do spec browser (não há "fatia reduzida" — era só servidor).
2. **Testes mistos:** o browser mantém uma versão **reduzida à fatia client com nome descritivo da interação** (ex. "staff reaches the concepts page via the tooltip and the card popover"), e o HTTP herda o nome original completo. O nome reduzido deixa explícito que aquele teste já não cobre a fatia servidor — grep por nome original aponta só para o HTTP.
3. **Nenhuma mudança de testMatch/config**: `campaign*Http.e2e.spec.ts` já casa `/campaign.*\.e2e\.spec\.ts/` no projeto `campaign` (playwright.config.ts:172) e o `E2E_SPEC_RE`/`--no-deps` (prod mode zera deps) funcionam como estão.
4. Cabeçalho espelho nos dois arquivos ("HTTP twin: …" / "Browser twin: …") listando o par — o ponto de partida da revisão contra drift.
**Rejeitadas:** B puro porque, sem o nome herdado, a auditoria "nada removido sem equivalente" fica implícita (exigiria tabela de mapeamento manual que já se sabe que envelhece); C porque renomear arquivos mexe no pin por nome do manifest (`e2e-affected-manifest.mjs` casa `tests/e2e/<name>.e2e.spec.ts`), no `ci-scope` e no testMatch — custo de infra sem ganho, já que o padrão atual casa os dois padrões de nome.
### Decisão 3 — Medição (forma dos dados)
**Opções:** A) rodada manual documentada (como o OPS35 fez: wall time do reporter list, anotado no changelog). B) script comitado `scripts/measure-e2e-family.mjs` que roda os specs selecionados com `--reporter=json` e imprime tabela por família (`specs[].tests[].results[].duration`). C) integração em CI com job que compara tempos.
**Recomendação:** B com o **registro** de A: o script é comitado e reproduzível; a execução dele alimenta a tabela do changelog do item. Protocolo: (1) **baseline pré-migração** — antes de editar qualquer spec, rodada prod-build (`E2E_PROD=1 CI=1`) dos 3 specs originais, tempos registrados; (2) **pós** — mesma rodada com os specs novos + reduzidos; (3) tabela por família: browser pré / browser pós / HTTP / Δ, no changelog. Medir sempre em **prod build** (dev-mode cold compile polui o comparativo; OPS35 já mediu os dois e o gate roda prod). Uma rodada por lado; se a variação visível entre testes do mesmo spec for alta, uma segunda rodada e média — nunca a melhor rodada.
**Rejeitadas:** A sozinho porque não é reproduzível e a intenção exige "medido de verdade" com a forma adiada a este plano — script comitado é a forma; C porque comparar tempos em CI é anti-pattern (variação de máquina no hosted runner, flake de medição) e viraria gate de deploy — viola "deploy não bloqueado"; o e2e full job já colhe o ganho agregado real no relatório.
### Decisão 4 — Extração do `rendered()`
**Opções:** A) mover o strip `<!-- -->` do Territories para o fixture base `campaignHttpTest.ts` e exportar `rendered(html)`. B) manter local em cada spec.
**Recomendação:** A — a primeira família do lote (Concepts) já precisa do strip; o base é o owner natural do utilitário de parsing do modo HTTP; Territories passa a importar do base (refactor de 1 linha, sem mudança de asserção). Gatilho do explorador ("mover na 2ª família") cumprido com folga: extrai-se antes da 2ª família.
**Rejeitadas:** B porque duplica conhecimento em N specs que compartilham o mesmo problema de transporte (DRY ≥3 call sites no lote 1).
### Decisão 5 — O que fica no browser (mapeamento 1:1)
Convenção de leitura: `→ HTTP` = teste migrado para o spec HTTP com o nome original (sai do browser); `→ fica (reduzido)` = teste misto reduzido à fatia client no browser com novo nome; `→ fica` = teste client puro, intocado.
**Concepts (2 testes):**
| Teste original | Classificação | Destino |
|---|---|---|
| staff reads the concepts page and reaches it from the goal-account card | MISTO | HTTP (fatia server: sidebar `href="/campanha/conceitos"` no HTML, headings "Cobertura da meta"/"Teto do campo (projetado)" no HTML de `/campanha/conceitos`, deep-link `campaignConceptHref('captura')` → `article:target` com `id="captura"`) + fica (reduzido) "staff reaches the concepts page via the tooltip and the card popover" (hover tooltip → href "Saiba mais"; popover "Como cada número é calculado" → navegação) |
| leader cannot open the concepts page | SERVIDOR | → HTTP (redirect para `/campanha/meus-contatos` no padrão [200,307,308] pinando destino; ausência de `href="/campanha/conceitos"` no HTML do líder) |
**PermissionProfile (6 testes — todos SERVIDOR):**
| Teste original | Destino |
|---|---|
| municipality list has no write controls and the FAB is absent | → HTTP (ausência de `[data-slot="campaign-quick-actions-fab"]` e de links `/Nova|Criar|Editar/i` no HTML de `/campanha/municipios`) |
| demands list has no "Nova demanda" button | → HTTP (ausência do link "Nova demanda" no HTML de `/campanha/demandas`) |
| activities list has no create buttons | → HTTP (ausência de botões `/Planejar|Nova atividade/i` no HTML de `/campanha/atividades`) |
| supporters list has no "Novo" or "Importar CSV" buttons | → HTTP (ausência do link "Novo" no HTML de `/campanha/apoiadores`) |
| rows outside the portfolio are read-only | → HTTP (presença do FAB no HTML de `/campanha/municipios` para `carteira`) |
| write controls are present | → HTTP (presença do FAB no HTML para `tudo`) |
Nota: o `waitForFunction` do gate `S:` (C106) não migra — é estabilização de DOM de browser, não asserção. Se, na implementação, o FAB só montar pós-hidratação (stream), a asserção fica no browser (fail-closed: não migra sem equivalente) — risco registrado abaixo. Spec browser deletado (0 testes restantes).
**People (12 testes):**
| Teste original | Classificação | Destino |
|---|---|---|
| staff opens the people list and sees the merged leadership person | MISTO | HTTP (fatia server: GET `/campanha/pessoas` → row do `contactName` e `/^\d+ pessoas?$/` no HTML; GET `/campanha/pessoas?q=<prefixo>` → row filtrada — o filtro é URL contract server-side) + fica (reduzido) "the omnibox narrows the people recorte" (fill + Enter do combobox) |
| the desktop table reads C130: Dobra em, city under the name, party filter | SERVIDOR | → HTTP (columnheader "Dobra em" presente/"Base" ausente no HTML; `nameCell` contém `municipality.name`; GET `?party=PT` → row "Seed Deputada Estadual" presente, row do contactName ausente; chip "Partido: PT" — se flight-only, mantém o URL contract que é o que importa) |
| the name link in the list opens the person detail (C118 entry point) | SERVIDOR | → HTTP (link com `href="/campanha/pessoas/<contactId>"` no HTML da lista; GET do detalhe → chrome "Pessoa" + heading "Liderança" no HTML) |
| leader cannot open the people page | SERVIDOR | → HTTP (redirect `/campanha/meus-contatos` [200,307,308]; HTML do líder sem o link Pessoas) |
| leader cannot open the person detail route | SERVIDOR | → HTTP (GET `/campanha/pessoas/99999` → redirect para `/campanha/meus-contatos`; heading "Meus contatos" no HTML) |
| coordinator opens the person detail and sees the sections of her capacities | SERVIDOR | → HTTP (headings Ficha + Liderança presentes, Dobradinha/Assessorado ausentes; link "Abrir detalhe de liderança" com `href="/campanha/liderancas/<id>"`; texto "Engajado" na região Liderança; WhatsApp/Convidar/Apagar no HTML — botões client-only ficam no browser, fail-closed) |
| coordinator sees dobradinha and apoiador sections only for a person who has them | SERVIDOR | → HTTP (headings Ficha + Dobradinha + Apoiador presentes, Liderança ausente; "PCdoB"/"Certo" nas regiões) |
| staff sorts by a column header and filters absence via the omnibox (C117) | MISTO | HTTP (fatia server: URL contract — GET `?q=C117&sort=lidera` → "Duas" antes de "Uma" no HTML das rows; `&dir=asc` → ordem invertida; `&ausencia=sem_contato[&sem_base][&sem_assessor]` → rows filtradas + chips "Ausência: …" no HTML) + fica (reduzido) "staff sorts by a column header and picks absence facets in the omnibox (C117)" (cliques no header + `aria-sort` + seleção de options) |
| mobile discovers ordering in the omnibox and applies a sort (C125) | CLIENT | → fica (viewport 390; não migra) |
| the Lidera cell offers Salvador as one aggregate and saves all 19 zones (C131) | CLIENT | → fica (combobox + `expectPostResponse` + poll no payload) |
| coordinator gives a person the first leadership municipality and ends it through the dialog (C128) | CLIENT | → fica (combobox + dialog + POST + polls) |
| the Assessora cell creates the staff account on the first municipality (C128) | CLIENT | → fica (combobox + POST + poll) |
**HomeActions (18 testes — lote 2, classificação registrada para o item seguinte):** SERVIDOR (4): "staff sees campaign search on home", "leader does not see campaign search on home" (B47), "legacy cenario param redirects to canonical URL", "stale entry param is ignored — never a skip link (B168)". MISTO (5): "staff search shows municipality hits and opens detail (B48)" (hits client; abertura por URL fica como contrato), "staff focused search without curated suggestions shows the honest empty state (OPS29)" (initialSuggest é SSR → migra quase 100%), "staff sees six home actions and can open municipalities without coverage" (B45: hrefs/URLs server; geometry de 3 colunas fica no browser), "leader sees two home actions and can open contacts" (B45), "renders the unified fields without legacy signal-type navigation" (C87: campos no HTML; save/toast fica). CLIENT (9): B66 ×2, B60, B61/B77 ×3, B70, C87 standalone, B97. Helpers client não reutilizáveis: `expectPostResponse`, `actionGridGeometry` (`collectActionBoundingBoxes`/`assertThreeColumnActionGrid`), `campaignPageChrome` (locator) — o HTTP exige equivalentes em texto quando a família migrar.
## Fases verificáveis
1. **Fase 0 — Baseline + ferramenta de medição** (~15% do appetite): escrever `scripts/measure-e2e-family.mjs`; rodada **baseline pré-migração** prod-build dos 3 specs originais (concepts, permission-profile, people); tempos anotados. _Verificação: script roda e imprime a tabela com os 3 specs; baseline registrado._
2. **Fase 1 — Fixture base + Concepts** (~15%): exportar `rendered()` no `campaignHttpTest.ts`; refatorar `campaignTerritoriesHttp` para importá-lo; criar `campaignConceptsHttp.e2e.spec.ts` (2 testes); reduzir `campaignConcepts.e2e.spec.ts` (1 teste client); manifest: entry conceitos ganha `campaignConceptsHttp`. _Verificação: `pnpm test:e2e --no-deps -- tests/e2e/campaignConceptsHttp.e2e.spec.ts` e o spec browser reduzido verdes em dev; Territories segue verde._
3. **Fase 2 — PermissionProfile** (~20%): criar `campaignPermissionProfileHttp.e2e.spec.ts` (6 testes, nomes originais); deletar `campaignPermissionProfile.e2e.spec.ts`; manifest: entrada própria para o domínio advisor/access com `campaignPermissionProfileHttp`; unit test do pin atualizado. _Verificação: spec novo verde em dev e prod; `--list` do projeto campaign sem o spec deletado._
4. **Fase 3 — People** (~30%): criar `campaignPeopleHttp.e2e.spec.ts` (8 testes: 6 server + 2 mistos); reduzir `campaignPeople.e2e.spec.ts` (6 testes: 2 reduzidos + 4 client); manifest: entry pessoas ganha `campaignPeopleHttp`. _Verificação: os dois specs verdes em dev e prod; os 4 testes client intocados (nomes idênticos no diff)._
5. **Fase 4 — Medição pós + registro + gates** (~20%): rodada pós com `measure-e2e-family.mjs`; tabela browser pré/pós vs HTTP no changelog (`docs/changelog/2026-08-24-ops87.md` + `pnpm changelog:build`); `pnpm gate:fast`; `pnpm test:e2e:affected` com o diff do PR (classificador seleciona os specs novos via manifest); PR. _Verificação: tabela completa no changelog; gate:fast verde; PR com o par espelho por família._
6. **Fase 5 — Gatilho lote 2 (só se sobrar ≥4 h do appetite)**: iniciar a fatia servidor de HomeActions dentro do item, começando pelos 4 testes 100% server; se o tempo faltar no meio, para-se no ponto (nunca bloqueia o deploy do lote 1) e o resto vira item novo com a classificação desta Decisão 5.
## Rabbit holes / Não escopo (engenharia)
- **Reescrever toda a suíte** — corte: 3 famílias no lote 1; o resto é lote documentado, nunca promessa dentro do item.
- **Separar as fatias de HomeActions no lote 1** — é o custo mais alto com o menor retorno; está na Fase 5 apenas como gatilho.
- **Gate de tempo em CI** — comparar tempos como check bloqueia deploy (Decisão 3, rejeitada C).
- **Assertar RSC flight payload como conteúdo** — o padrão é ausência/presença de controle (lição do Territories: "Cobertura fica oculta" = ausência do sortable, não ausência do label que vive no flight).
- **Viewport/CSS/geometry no HTTP** — rung de coluna, `overflowX`, bounding boxes, `aria-sort` e `data-retracted` são browser; o HTTP assere presença/ausência e URL contract, nunca layout.
- **Fixture nova de login/ownership** — reusar `campaignRequest`/`campaignFixture`/`createCampaignOwnership`; qualquer teste que precise de sessão compartilhada usa `mintCampaignSession`/`seedCampaignSession` (padrão do HomeActions), não duplica.
- **Mexer em testMatch/projetos do config** — os specs novos já casam; mudança de config é OPS88 (CI/deploy), fora de escopo.
- **Consertar flakes de famílias não migradas** — fora de escopo da intenção.
## Riscos e mitigação
- **Drift entre spec browser e HTTP ao longo do tempo** — nomes herdados (o HTTP carrega o nome original, auditável por `git log`/`-g`), cabeçalhos espelho "twin" nos dois arquivos, e a revisão do PR confere o par família a família. Risco residual aceito: o drift exige disciplina de revisão; a medição por família (rodada pós) reaparece no changelog a cada lote e denuncia asserção órfã que passou a falhar em só um dos lados.
- **Elemento só monta pós-hidratação (RSC flight/stream)** — label existe só no flight, ou o FAB vem de chunk streamado: asserção HTTP impossível sem reduzir garantia. Mitigação: padrão do Territories (ausência/presença de controle renderizado) e **fail-closed** — se não houver equivalente server-side, a asserção fica no browser e o mapeamento registra a exceção. Nenhum teste é removido por causa disso.
- **Redirect em streaming (200 + `http-equiv="refresh"` vs 3xx)** — o `noLeader` gate lança redirect após o layout começar a streamar: aceitar `[200,307,308]`, pinar o destino (`location` ou meta refresh), nunca o transporte. Padrão do Territories, copiado 1:1 nos redirects de leader de Concepts/People.
- **Flake sob 4 workers nos specs novos** — os specs HTTP usam o mesmo ownership compartilhado (`claimMunicipality` com runID, `createCampaignUser` com nome/email únicos, cleanup transacional) — nenhuma corrida nova; e a migração **remove** interação client (menos classes de flake). Validação em prod build com `PLAYWRIGHT_WORKERS=4` antes do PR.
- **HTML cru com encoding/acentos** — `Irec%C3%AA` já é lição do Territories; hrefs com acento precisam do encode explícito nos assert de substring.
- **Unit test do manifest pina os nomes dos specs** — os arquivos novos existem antes do pin ser atualizado (ordem da Fase 2); um pin que falha trava o `gate:fast`, não o deploy.
- **Medição enviesada (dev vs prod, máquina)** — protocolo fixo: prod build (`CI=1 E2E_PROD=1`), mesma máquina, pares medidos na mesma rodada; baseline colhido **antes** de editar (senão o "antes" vira reconstrução).
- **Appetite estoura** — corte explícito na Decisão 1; Fase 5 é gatilho, não meta; o que não couber vira item novo com a classificação registrada.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: ganho **medido** (nunca estimativa) por família migrada e registrado no changelog (tabela browser pré/pós vs HTTP); cobertura não diminui (mapa 1:1 da Decisão 5 sem exceção não registrada); deploy não fica bloqueado no meio do lote (Fases 1–4 são um lote fechado; Fase 5 é gatilho).
- [ ] Invariantes AGENTS/engineering-standards: spec HTTP estende o **raw** `@playwright/test` (nunca `e2eTest.ts` — guard `e2eFailureGuard` fica exclusivo de browser); reusa `campaignFixture`, `campaignRequest`, `claimMunicipality`, `createCampaignUser`, `createStaffLeadership` — nenhuma fixture nova duplicada (depth check).
- [ ] Auth real: `POST /api/campaignUser/login` (email OU username) + cookie `campaign-token` via `campaignTokenStorageState` — nenhuma lógica de login duplicada; redirects pinam destino, nunca transporte.
- [ ] `rendered()` extraído para o fixture base e `campaignTerritoriesHttp` refatorado (sem mudança de asserção).
- [ ] Manifest `e2e-affected-manifest.mjs` atualizado (concepts/people ganham os `Http`; permission-profile ganha entrada própria); unit test do pin verde; novos specs selecionáveis por `E2E_SPEC_RE` e pelo classificador (OPS86).
- [ ] Specs novos verdes em dev **e** prod build; `pnpm gate:fast` verde; `pnpm test:e2e:affected` seleciona os specs novos no PR.
- [ ] Nenhum teste removido sem equivalente: o diff mostra nomes herdados (auditoria 1:1) e os testes mistos reduzidos têm nome descritivo da fatia client que permanece.
- [ ] Medição: rodada baseline pré-migração colhida antes da Fase 1; rodada pós com `measure-e2e-family.mjs`; tabela por família no changelog do item.
**Self-score decision-quality: 5/5** — (1) as 5 decisões caras têm opções + rejeitadas registradas; (2) abordagem cabe no appetite com corte explícito (HomeActions fora do lote 1) e fases com quota; (3) rabbit holes nomeados e não escopo do item; (4) depth check: reusa fixture/ownership/auth existentes do OPS35, nada de fixture paralela; (5) aceite da intenção preservado — medição por família é fase dedicada e condição de aceite, não cortesia.
## Débitos deferidos (simplify, pós-entrega)
Lote único **"pins frágeis dos specs HTTP"** — achados do simplify score ≤3, nenhum bloqueante: (a) `indexOf` de ordem sobre HTML bruto no C117 HTTP (risco teórico de reordenação de streaming); (b) regex `Nova|Criar|Editar` do PermissionProfile com falsos positivos data-dependentes (municípios "Nova X"); (c) header do `e2e-affected-manifest.mjs` ("specs never import app code") contradiz o import de `campaignConceptHref`; (d) pin `/<th...>Base</th>/` do C130 perde header aninhado; (e) substrings casam flight payload (semântica levemente larga, coberta pelo fail-closed); (f) literal `aria-label` do Concepts pina formatação exata.
**Gatilho:** primeiro falso positivo/flake data-dependent nos specs HTTP **ou** início do lote 2 (HomeActions), quando os pins forem reusados — appetite estimado ~0,5–1h. Descartados: `families.size`/maxBuffer do measure script (ferramenta não-CI), sufixo "(browser)" nos describes, tamanho dos testes longos (aceitável), flake de login dev OPS30 (pré-existente, fora de escopo), lote 2 de HomeActions (já coberto pela Decisão 5).

View file

@ -120,7 +120,9 @@ export const E2E_AFFECTED_MANIFEST = [
},
{
prefixes: [`${CAMPAIGN_APP}/conceitos`],
specs: ['campaignConcepts'],
// OPS87 — server slice migrated to the browserless HTTP mode; the browser
// spec keeps the tooltip/popover interactions.
specs: ['campaignConceptsHttp', 'campaignConcepts'],
},
{
prefixes: [
@ -177,7 +179,9 @@ export const E2E_AFFECTED_MANIFEST = [
'src/utilities/people',
'src/utilities/campaignSavedFilterStore',
],
specs: ['campaignPeople'],
// OPS87 — server slice migrated to the browserless HTTP mode; the browser
// spec keeps the omnibox/combobox interactions and the dialog writes.
specs: ['campaignPeopleHttp', 'campaignPeople'],
},
{
prefixes: [
@ -217,6 +221,25 @@ export const E2E_AFFECTED_MANIFEST = [
},
// Domains without a dedicated e2e family still wake campaign home smoke so
// the affected classifier cannot return mode=none on an unmapped domain dir.
{
prefixes: [
// OPS87 — C142 advisor permission profiles: the write-control gating
// lives in the advisor/access domain plus the FAB mount decision
// (`CampaignQuickActionsHost/Fab` in the shell), exercised on the write
// surfaces of these routes (FAB, "Nova demanda", create buttons).
'src/lib/campaignAdvisorProfile',
'src/lib/campaignQuickActionMount',
'src/lib/campaignQuickActionRegistry',
'src/components/campaign/shell',
'src/utilities/advisor',
'src/utilities/advisorData',
`${CAMPAIGN_APP}/atividades`,
`${CAMPAIGN_APP}/apoiadores`,
`${CAMPAIGN_APP}/demandas`,
`${CAMPAIGN_APP}/municipios`,
],
specs: ['campaignPermissionProfileHttp'],
},
{
prefixes: [
'src/components/campaign/advisor',

View file

@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* measure-e2e-family (OPS87) — reproducible per-family e2e timing, the tool
* behind the pyramid's acceptance criterion: "tempo browser vs tempo HTTP,
* medido de verdade, nunca estimativa".
*
* Runs the given specs against the PRODUCTION build with `--reporter=json`
* and prints one table per spec file: per-test durations plus totals. Dev
* mode is refused on purpose — cold webpack compiles pollute the comparativo
* (OPS34 measured it), and the deploy gate itself runs the prod build.
*
* NEXT_DIST_DIR=.next-e2e pnpm build # once, before the first run
* node scripts/measure-e2e-family.mjs tests/e2e/campaignConcepts.e2e.spec.ts \
* tests/e2e/campaignPermissionProfile.e2e.spec.ts
*
* Args accept full repo-relative spec paths or bare family names
* (`campaignPeople` → `tests/e2e/campaignPeople.e2e.spec.ts`).
*
* Protocol (impl plan Decisão 3): same machine, same `PLAYWRIGHT_WORKERS`
* (default 2), baseline colhido ANTES de editar os specs; a tabela alimenta o
* changelog do item. Durations come from Playwright's own JSON report
* (`suites[].specs[].tests[].results[].duration`, ms) — wall time per test
* including fixture time, never an estimate.
*/
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const usage = () => {
console.error(
[
'usage: node scripts/measure-e2e-family.mjs <spec...>',
' spec — repo-relative spec path or bare family name (campaignPeople).',
'requires: E2E_PROD=1 and an existing production build in .next-e2e',
' (NEXT_DIST_DIR=.next-e2e pnpm build).',
].join('\n'),
)
process.exit(2)
}
const args = process.argv.slice(2)
if (args.length === 0) usage()
const specPaths = args.map((arg) => (arg.includes('/') ? arg : `tests/e2e/${arg}.e2e.spec.ts`))
for (const specPath of specPaths) {
if (!existsSync(path.join(repoRoot, specPath))) {
console.error(`[measure] spec not found: ${specPath}`)
usage()
}
}
if (process.env.E2E_PROD !== '1') {
console.error(
'[measure] refusing dev-mode measurement — cold compiles pollute the timing. Run with E2E_PROD=1.',
)
process.exit(2)
}
if (!existsSync(path.join(repoRoot, '.next-e2e'))) {
console.error(
'[measure] no production build in .next-e2e — run `NEXT_DIST_DIR=.next-e2e pnpm build` first.',
)
process.exit(2)
}
const workers = process.env.PLAYWRIGHT_WORKERS ?? '2'
console.log(`[measure] prod build, workers=${workers}, specs: ${specPaths.length}`)
const result = spawnSync('pnpm', ['test:e2e', '--no-deps', '--reporter=json', '--', ...specPaths], {
cwd: repoRoot,
encoding: 'utf8',
env: { ...process.env, E2E_PROD: '1', PLAYWRIGHT_WORKERS: workers },
maxBuffer: 64 * 1024 * 1024,
})
if (result.status !== 0) {
console.error(`[measure] playwright exited ${result.status}`)
console.error(result.stderr ?? '')
console.error((result.stdout ?? '').slice(-4000))
process.exit(result.status ?? 1)
}
// `pnpm` echoes its script banner to stdout before Playwright's JSON report.
// The report object opens with the `config` key — anchor on it instead of the
// first `{`, so a stray brace in a banner line cannot corrupt the slice.
const jsonStart = result.stdout.indexOf('{"config"')
if (jsonStart === -1) {
console.error('[measure] playwright produced no JSON report on stdout')
process.exit(2)
}
const report = JSON.parse(result.stdout.slice(jsonStart))
/**
* JSON reporter shape (Playwright 1.5x): describe-level suites carry `file`
* and `specs[]`; a spec IS one test — `spec.title` names it and
* `spec.tests[].results[].duration` holds the retry-run wall times (ms).
* Describe suites share the same `file`, so entries are merged per file.
* @returns {Array<{ file: string, ok: boolean, tests: Array<{ title: string, duration: number }> }>}
*/
const collectSpecs = (suite) => {
const found = []
if (suite.file && suite.specs?.length) {
found.push({
file: suite.file,
ok: suite.specs.every((spec) => spec.ok),
tests: suite.specs.map((spec) => ({
title: spec.title,
duration: spec.tests.reduce(
(sum, test) => sum + test.results.reduce((acc, entry) => acc + (entry.duration ?? 0), 0),
0,
),
})),
})
}
for (const child of suite.suites ?? []) found.push(...collectSpecs(child))
return found
}
const families = new Map()
for (const entry of collectSpecs({ suites: report.suites })) {
const family = families.get(entry.file) ?? { file: entry.file, ok: true, tests: [] }
family.ok = family.ok && entry.ok
family.tests.push(...entry.tests)
families.set(entry.file, family)
}
const fmt = (ms) => `${(ms / 1000).toFixed(1)}s`
let grandTotal = 0
for (const family of families.values()) {
const total = family.tests.reduce((sum, test) => sum + test.duration, 0)
grandTotal += total
console.log(
`\n${family.file} — ${family.tests.length} tests, ${fmt(total)}${family.ok ? '' : ' (FAILED)'}`,
)
for (const test of family.tests) console.log(` ${fmt(test.duration).padStart(6)} ${test.title}`)
}
console.log(`\nTOTAL: ${fmt(grandTotal)} across ${families.size} spec file(s)`)

View file

@ -2,12 +2,17 @@ import { campaignConceptHref } from '../../src/lib/campaignIntelligenceConcepts.
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* E18 — `/campanha/conceitos` documents staff-only numbers (estimates, goals,
* field ceiling), so the route gate and the "Saiba mais" path out of the
* "Conta da cadeira" card are what these tests protect.
* OPS87 — client slice of `campaignConcepts` after the HTTP migration. The
* route gate, the rendered content and the deep-link anchors moved to
* tests/e2e/campaignConceptsHttp.e2e.spec.ts (same original test names, 1:1);
* what remains here is the interaction only a browser can exercise: the
* tooltip and the card popover mount their links on open, so their content
* never lives in the server HTML.
*
* HTTP twin: tests/e2e/campaignConceptsHttp.e2e.spec.ts.
*/
test.describe('Conceitos de inteligência', () => {
test('staff reads the concepts page and reaches it from the goal-account card', async ({
test('staff reaches the concepts page via the tooltip and the card popover', async ({
campaign,
page,
}) => {
@ -21,13 +26,13 @@ test.describe('Conceitos de inteligência', () => {
await campaign.login(page, email, password)
// Always-visible way in, at the foot of the sidebar.
await page.getByRole('link', { name: 'Conceitos', exact: true }).click()
await expect(page).toHaveURL(/\/campanha\/conceitos$/)
await page.goto(`/campanha/municipios/${municipality.slug}`)
const goalAccount = page.getByRole('region', { name: 'Conta da cadeira' })
await expect(goalAccount).toBeVisible()
// C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell;
// a hover landing before hydration is a silent no-op (the B13/B17 flake
// class), so let the streamed shell settle first.
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
// Per-metric tooltips carry a deep link to the matching anchor.
await goalAccount.getByRole('button', { name: /Captura \(2022\): mais informações/ }).hover()
@ -45,26 +50,10 @@ test.describe('Conceitos de inteligência', () => {
await expect(page.getByRole('heading', { name: 'Cobertura da meta' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Teto do campo (projetado)' })).toBeVisible()
// Deep link lands on the documented concept, not just on the page.
// Deep link lands on the documented concept and the browser applies the
// `:target` tint — the HTTP twin proves the anchor exists in the server
// HTML, this proves the fragment navigation itself.
await page.goto(campaignConceptHref('captura'))
await expect(page.locator('article:target')).toHaveAttribute('id', 'captura')
})
test('leader cannot open the concepts page', async ({ campaign, page }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança Conceitos'),
username: phone,
})
const password = leader.password
await campaign.login(page, phone, password)
await expect(page.getByRole('link', { name: 'Conceitos', exact: true })).toHaveCount(0)
await page.goto('/campanha/conceitos')
await expect(page).toHaveURL(/\/campanha\/meus-contatos\/?$/)
await expect(campaignPageChrome(page, 'Conceitos de inteligência')).toHaveCount(0)
})
})

View file

@ -0,0 +1,78 @@
import { campaignConceptHref } from '../../src/lib/campaignIntelligenceConcepts.js'
import { assertLeaderRedirect, expect, rendered, test } from './fixtures/campaignHttpTest.js'
/**
* OPS87 — server slice of `campaignConcepts` migrated to the browserless HTTP
* mode (same server, same database, real HTTP, no browser). What the browser
* spec read from the DOM is read from the rendered HTML; what it navigated by
* click is requested by URL.
*
* Browser twin: tests/e2e/campaignConcepts.e2e.spec.ts — the browser file keeps
* the tooltip/popover interactions (client-only: Radix mounts their content
* on open, so the links never live in the server HTML); this file carries the
* original test names so the 1:1 migration is auditable by `git log`/`-g`.
* The client-failure guard (console.error/pageerror) stays exclusive to
* browser specs — the HTTP mode does not pretend to observe what it cannot
* see (OPS35).
*/
test.describe('Conceitos de inteligência (HTTP)', () => {
test('staff reads the concepts page and reaches it from the goal-account card', async ({
campaign,
campaignRequest,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Conceitos'),
})
const municipality = await fixtures.claimMunicipality()
const request = await campaignRequest(coordinator, coordinator.password)
// The municipality detail renders the "Conta da cadeira" card server-side,
// with the per-metric tooltip buttons as its affordances (their content is
// client-only; the entry point is not).
const detail = await request.get(`/campanha/municipios/${municipality.slug}`)
expect(detail.status()).toBe(200)
const detailHTML = rendered(await detail.text())
expect(detailHTML).toContain('Conta da cadeira')
expect(detailHTML).toContain('aria-label="Captura (2022): mais informações"')
// Always-visible way in, at the foot of the sidebar — the same link the
// browser spec clicks.
expect(detailHTML).toContain('href="/campanha/conceitos"')
// The page itself: the documented concepts render as server HTML headings.
const concepts = await request.get('/campanha/conceitos')
expect(concepts.status()).toBe(200)
const conceptsHTML = rendered(await concepts.text())
expect(conceptsHTML).toContain('Cobertura da meta')
expect(conceptsHTML).toContain('Teto do campo (projetado)')
// Deep link lands on the documented concept: the anchor article exists in
// the server HTML (`article:target` is what the browser tints).
const deep = await request.get(campaignConceptHref('captura'))
expect(deep.status()).toBe(200)
expect(rendered(await deep.text())).toContain('<article id="captura"')
})
test('leader cannot open the concepts page', async ({ campaign, campaignRequest }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança Conceitos'),
username: phone,
})
// Log in by username (phone), the way the browser spec did — the REST
// login accepts email or username and both branches are exercised.
const request = await campaignRequest({ username: phone }, leader.password)
// The `staff` gate throws `redirect()` deep in the page, after the layout
// has started streaming — same contract the Territories spec pins: assert
// the redirect target, never the transport.
await assertLeaderRedirect(request, '/campanha/conceitos')
// The leader home renders no way in.
const home = await request.get('/campanha/meus-contatos')
expect(home.status()).toBe(200)
expect(await home.text()).not.toContain('href="/campanha/conceitos"')
})
})

View file

@ -13,240 +13,18 @@ import type { CampaignUser } from '../../src/payload-types.js'
* merged person row, and the route stays locked for leaders.
*/
test.describe('Pessoas — lista unificada', () => {
test('staff opens the people list and sees the merged leadership person', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Pessoas'),
})
const municipality = await fixtures.claimMunicipality()
const { contactName } = await fixtures.createStaffLeadership({
namePrefix: 'Liderança Pessoas',
municipalities: [municipality],
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.getByRole('link', { name: 'Pessoas', exact: true }).click()
await expect(page).toHaveURL(/\/campanha\/pessoas$/)
await expect(campaignPageChrome(page, 'Pessoas')).toBeVisible()
// The merged row carries the leadership person with its capacity chips
// (desktop table — the mobile card twin is `md:hidden` at this viewport).
// The people list streams RSC chunks; under load the row can land after
// `goto`'s settle, so let the stream commit and then poll the DOM for the
// specific row (same class as the `S:` gate at campaignMunicipalities:708,
// observed as a flake in the OPS83 verify run #15 at 4 workers).
await waitForStreamSettled(page)
await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible()
// The coordinator account itself is a staff person (with a ficha) — ≥ 2 rows.
await expect(page.getByText(/^\d+ pessoas?$/)).toBeVisible()
// The omnibox search narrows the recorte (canonical URL, chips mounted).
const omnibox = page.getByRole('combobox', { name: 'Filtrar pessoas' })
await omnibox.fill(contactName.slice(0, 8))
await omnibox.press('Enter')
await expect(page).toHaveURL(/q=/)
// The filter navigation re-streams the list — let it commit before the row.
await waitForStreamSettled(page)
await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible()
})
test('the desktop table reads C130: Dobra em, city under the name, party filter', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora C130'),
})
const municipality = await fixtures.claimMunicipality()
const { contactName } = await fixtures.createStaffLeadership({
namePrefix: 'C130 Liderança',
municipalities: [municipality],
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/pessoas')
// Renamed column, base column gone (the city now lives under the name).
await expect(page.getByRole('columnheader', { name: /Dobra em/ })).toBeVisible()
await expect(page.getByRole('columnheader', { name: /Base/ })).not.toBeVisible()
const nameCell = page
.getByRole('row', { name: new RegExp(contactName) })
.getByRole('cell')
.first()
await expect(nameCell).toContainText(municipality.name)
// The seeded dobradinha's party filters the recorte (omnibox facet is
// unit-tested; here the canonical URL drives the server-side filter and
// the chip renders from state).
await page.goto('/campanha/pessoas?party=PT')
await expect(page.getByRole('row', { name: /Seed Deputada Estadual/ })).toBeVisible()
await expect(page.getByRole('row', { name: new RegExp(contactName) })).not.toBeVisible()
await expect(page.getByText('Partido: PT').first()).toBeVisible()
})
test('the name link in the list opens the person detail (C118 entry point)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
const { contactId, contactName } = await fixtures.createStaffLeadership({
namePrefix: 'Nome Link Detalhe',
municipalities: [municipality],
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/pessoas')
// The list streams RSC chunks; clicking the name link against the stale DOM
// races the stream (OPS83: C118 flaked under parallel load).
await waitForStreamSettled(page)
await page.getByRole('link', { name: contactName, exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/campanha/pessoas/${contactId}$`))
await expect(campaignPageChrome(page, 'Pessoa')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Liderança' })).toBeVisible()
})
test('leader cannot open the people page', async ({ campaign, page }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança Sem Acesso'),
username: phone,
})
await campaign.login(page, phone, leader.password)
await page.goto('/campanha/pessoas')
await expect(page).toHaveURL(/\/campanha\/meus-contatos/)
await expect(page.getByRole('heading', { name: 'Meus contatos' })).toBeVisible()
})
test('leader cannot open the person detail route', async ({ campaign, page }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança Detalhe Sem Acesso'),
username: phone,
})
await campaign.login(page, phone, leader.password)
await page.goto('/campanha/pessoas/99999')
await expect(page).toHaveURL(/\/campanha\/meus-contatos/)
await expect(page.getByRole('heading', { name: 'Meus contatos' })).toBeVisible()
})
test('coordinator opens the person detail and sees the sections of her capacities', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
const { contactId, contactName, leadershipId } = await fixtures.createStaffLeadership({
namePrefix: 'Liderança Detalhe',
municipalities: [municipality],
supportStatus: 'engajado',
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/pessoas/${contactId}`)
await expect(page).toHaveURL(new RegExp(`/campanha/pessoas/${contactId}$`))
await expect(campaignPageChrome(page, 'Pessoa')).toBeVisible()
await expect(
page
.locator('[data-slot="campaign-page-chrome"]')
.filter({ visible: true })
.getByText(contactName),
).toBeVisible()
// Sections mounted by capacity: a leadership-only person shows Ficha +
// Liderança, never an empty Dobradinha block.
await expect(page.getByRole('heading', { name: 'Ficha' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Liderança' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Dobradinha' })).not.toBeVisible()
await expect(page.getByRole('heading', { name: 'Assessorado' })).not.toBeVisible()
// The leadership section links to the rich existing detail (v1 coexistence).
await expect(page.getByRole('link', { name: 'Abrir detalhe de liderança' })).toHaveAttribute(
'href',
`/campanha/liderancas/${leadershipId}`,
)
await expect(
page.getByRole('region', { name: 'Liderança' }).getByText('Engajado'),
).toBeVisible()
// Actions: WhatsApp (phone exists) + delete (coordinator); invite for leaders.
await expect(page.getByRole('link', { name: 'Enviar WhatsApp' })).toBeVisible()
await expect(page.getByRole('button', { name: /Convidar/ })).toBeVisible()
await expect(page.getByRole('button', { name: `Apagar pessoa ${contactName}` })).toBeVisible()
})
test('coordinator sees dobradinha and apoiador sections only for a person who has them', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const deputyMunicipality = await fixtures.claimMunicipality()
const supporterMunicipality = await fixtures.claimMunicipality()
const contact = await fixtures.payload.create({
collection: 'contact',
data: {
name: fixtures.value('Dobradinha Detalhe'),
phones: [{ value: fixtures.phone() }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
})
const deputy = await fixtures.payload.create({
collection: 'stateDeputy',
data: { contact: contact.id, party: 'PCdoB', slug: fixtures.value('dobradinha-detalhe') },
depth: 0,
})
await fixtures.payload.update({
collection: 'municipality',
id: deputyMunicipality.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
overrideAccess: true,
})
fixtures.touchMunicipality(deputyMunicipality.id)
await fixtures.payload.create({
collection: 'supporter',
data: {
contact: contact.id,
municipality: supporterMunicipality.id,
source: 'evento',
voteIntention: 'certo',
},
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/pessoas/${contact.id}`)
await expect(page.getByRole('heading', { name: 'Ficha' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Dobradinha' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Apoiador' })).toBeVisible()
await expect(page.getByRole('region', { name: 'Dobradinha' }).getByText('PCdoB')).toBeVisible()
await expect(page.getByRole('region', { name: 'Apoiador' }).getByText('Certo')).toBeVisible()
// No leadership capacity → no leadership block, no invite.
await expect(page.getByRole('heading', { name: 'Liderança' })).not.toBeVisible()
await expect(page.getByRole('button', { name: /Convidar/ })).not.toBeVisible()
})
/**
* OPS87 — client slice of `campaignPeople` after the HTTP migration. The
* server-side assertions (list render, C130 columns, party filter, name link,
* leader redirects, detail sections, C117 URL contracts) moved to
* tests/e2e/campaignPeopleHttp.e2e.spec.ts with the original test names, 1:1;
* what remains here is the interaction only a browser can exercise: omnibox
* typing, sortable-header clicks with `aria-sort`, mobile card ordering, and
* the cell combobox/dialog writes (C125/C128/C131).
*
* HTTP twin: tests/e2e/campaignPeopleHttp.e2e.spec.ts.
*/
test('staff sorts by a column header and filters absence via the omnibox (C117)', async ({
campaign,
page,

View file

@ -0,0 +1,311 @@
import { assertLeaderRedirect, expect, rendered, test } from './fixtures/campaignHttpTest.js'
/**
* OPS87 — server slice of `campaignPeople` migrated to the browserless HTTP
* mode (same server, same database, real HTTP, no browser). What the browser
* spec read from the DOM is read from the rendered HTML; what it navigated by
* click is requested by URL. The detail sections pin their stable ids
* (`person-section-<capacity>`), immune to the sidebar's "Lideranças"/
* "Dobradinhas" link texts.
*
* Browser twin: tests/e2e/campaignPeople.e2e.spec.ts — the browser file keeps the
* omnibox/combobox interactions and the mobile/dialog writes; this file
* carries the original test names for the server-side assertions, so the 1:1
* migration is auditable by `git log`/`-g`. The client-failure guard stays
* exclusive to browser specs (OPS35).
*/
const section = (id: string) => `id="person-section-${id}"`
test.describe('Pessoas — lista unificada (HTTP)', () => {
test('staff opens the people list and sees the merged leadership person', async ({
campaign,
campaignRequest,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Pessoas'),
})
const municipality = await fixtures.claimMunicipality()
const { contactName } = await fixtures.createStaffLeadership({
namePrefix: 'Liderança Pessoas',
municipalities: [municipality],
})
const request = await campaignRequest(coordinator, coordinator.password)
// The merged row carries the leadership person (the coordinator account
// itself is a staff person with a ficha — ≥ 2 rows).
const list = await request.get('/campanha/pessoas')
expect(list.status()).toBe(200)
const listHTML = rendered(await list.text())
expect(listHTML).toContain(contactName)
expect(listHTML).toMatch(/\d+ pessoas?\b/)
// The omnibox search is a URL contract: the server renders the narrowed
// recorte for the same query the browser spec typed.
const filtered = await request.get(
`/campanha/pessoas?q=${encodeURIComponent(contactName.slice(0, 8))}`,
)
expect(filtered.status()).toBe(200)
expect(rendered(await filtered.text())).toContain(contactName)
})
test('the desktop table reads C130: Dobra em, city under the name, party filter', async ({
campaign,
campaignRequest,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora C130'),
})
const municipality = await fixtures.claimMunicipality()
const { contactName } = await fixtures.createStaffLeadership({
namePrefix: 'C130 Liderança',
municipalities: [municipality],
})
const request = await campaignRequest(coordinator, coordinator.password)
const response = await request.get('/campanha/pessoas')
expect(response.status()).toBe(200)
const html = rendered(await response.text())
// Renamed column, base column gone (the city now lives under the name —
// "Base: <city>" is cell text, never a column header).
expect(html).toContain('Dobra em')
expect(html).not.toMatch(/<th\b[^>]*>\s*Base\s*<\/th>/)
expect(html).toContain(municipality.name)
// The seeded dobradinha's party filters the recorte (canonical URL drives
// the server-side filter).
const party = await request.get('/campanha/pessoas?party=PT')
expect(party.status()).toBe(200)
const partyHTML = rendered(await party.text())
expect(partyHTML).toContain('Seed Deputada Estadual')
expect(partyHTML).not.toContain(contactName)
expect(partyHTML).toContain('Partido: PT')
})
test('the name link in the list opens the person detail (C118 entry point)', async ({
campaign,
campaignRequest,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
const { contactId, contactName } = await fixtures.createStaffLeadership({
namePrefix: 'Nome Link Detalhe',
municipalities: [municipality],
})
const request = await campaignRequest(coordinator, coordinator.password)
// The list rows link to the ficha.
const list = await request.get('/campanha/pessoas')
expect(list.status()).toBe(200)
expect(rendered(await list.text())).toContain(`href="/campanha/pessoas/${contactId}"`)
// The detail renders the chrome and the leadership capacity section.
const detail = await request.get(`/campanha/pessoas/${contactId}`)
expect(detail.status()).toBe(200)
const detailHTML = rendered(await detail.text())
expect(detailHTML).toContain(contactName)
expect(detailHTML).toContain(section('ficha'))
expect(detailHTML).toContain(section('leadership'))
})
test('leader cannot open the people page', async ({ campaign, campaignRequest }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança Sem Acesso'),
username: phone,
})
const request = await campaignRequest({ username: phone }, leader.password)
await assertLeaderRedirect(request, '/campanha/pessoas')
const home = await request.get('/campanha/meus-contatos')
expect(home.status()).toBe(200)
expect(await home.text()).toContain('Meus contatos')
})
test('leader cannot open the person detail route', async ({ campaign, campaignRequest }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança Detalhe Sem Acesso'),
username: phone,
})
const request = await campaignRequest({ username: phone }, leader.password)
await assertLeaderRedirect(request, '/campanha/pessoas/99999')
})
test('coordinator opens the person detail and sees the sections of her capacities', async ({
campaign,
campaignRequest,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
const { contactId, contactName, leadershipId } = await fixtures.createStaffLeadership({
namePrefix: 'Liderança Detalhe',
municipalities: [municipality],
supportStatus: 'engajado',
})
const request = await campaignRequest(coordinator, coordinator.password)
const response = await request.get(`/campanha/pessoas/${contactId}`)
expect(response.status()).toBe(200)
const html = rendered(await response.text())
// Sections mounted by capacity: a leadership-only person shows Ficha +
// Liderança, never an empty Dobradinha/Assessorado block.
expect(html).toContain(section('ficha'))
expect(html).toContain(section('leadership'))
expect(html).not.toContain(section('deputy'))
expect(html).not.toContain(section('assessorado'))
expect(html).toContain('Engajado')
// The leadership section links to the rich existing detail (v1 coexistence).
expect(html).toContain(`href="/campanha/liderancas/${leadershipId}"`)
// Actions: WhatsApp (phone exists), invite (leadership), delete (coordinator).
expect(html).toContain('>Enviar WhatsApp<')
expect(html).toContain('para completar cadastro por WhatsApp')
expect(html).toContain(`aria-label="Apagar pessoa ${contactName}"`)
})
test('coordinator sees dobradinha and apoiador sections only for a person who has them', async ({
campaign,
campaignRequest,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const deputyMunicipality = await fixtures.claimMunicipality()
const supporterMunicipality = await fixtures.claimMunicipality()
const contact = await fixtures.payload.create({
collection: 'contact',
data: {
name: fixtures.value('Dobradinha Detalhe'),
phones: [{ value: fixtures.phone() }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
})
const deputy = await fixtures.payload.create({
collection: 'stateDeputy',
data: { contact: contact.id, party: 'PCdoB', slug: fixtures.value('dobradinha-detalhe') },
depth: 0,
})
await fixtures.payload.update({
collection: 'municipality',
id: deputyMunicipality.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
overrideAccess: true,
})
fixtures.touchMunicipality(deputyMunicipality.id)
await fixtures.payload.create({
collection: 'supporter',
data: {
contact: contact.id,
municipality: supporterMunicipality.id,
source: 'evento',
voteIntention: 'certo',
},
depth: 0,
})
const request = await campaignRequest(coordinator, coordinator.password)
const response = await request.get(`/campanha/pessoas/${contact.id}`)
expect(response.status()).toBe(200)
const html = rendered(await response.text())
expect(html).toContain(section('ficha'))
expect(html).toContain(section('deputy'))
expect(html).toContain(section('supporter'))
expect(html).toContain('PCdoB')
expect(html).toContain('Certo')
// No leadership capacity → no leadership block, no invite.
expect(html).not.toContain(section('leadership'))
expect(html).not.toContain('para completar cadastro por WhatsApp')
})
test('staff sorts by a column header and filters absence via the omnibox (C117)', async ({
campaign,
campaignRequest,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora C117'),
})
const first = await fixtures.claimMunicipality()
const second = await fixtures.claimMunicipality()
const oneCity = await fixtures.createStaffLeadership({
namePrefix: 'C117 Ordenação Uma',
municipalities: [first],
})
const twoCities = await fixtures.createStaffLeadership({
namePrefix: 'C117 Ordenação Duas',
municipalities: [first, second],
})
// A person without a phone — the "Sem contato" absence facet target.
const noPhoneName = fixtures.value('C117 Sem Contato')
const noPhoneContact = await fixtures.payload.create({
collection: 'contact',
data: { name: noPhoneName, state: 'BA', city: 'Salvador' },
depth: 0,
})
await fixtures.payload.create({
collection: 'leadership',
data: { contact: noPhoneContact.id, municipalities: [second.id], supportStatus: 'a_abordar' },
depth: 0,
})
const request = await campaignRequest(coordinator, coordinator.password)
// First click: Lidera opens desc (most municipalities first) — the sort is
// a URL contract, the server renders the row order.
const desc = await request.get('/campanha/pessoas?q=C117&sort=lidera')
expect(desc.status()).toBe(200)
const descHTML = rendered(await desc.text())
expect(descHTML.indexOf(twoCities.contactName)).toBeGreaterThanOrEqual(0)
expect(descHTML.indexOf(oneCity.contactName)).toBeGreaterThanOrEqual(0)
expect(descHTML.indexOf(twoCities.contactName)).toBeLessThan(
descHTML.indexOf(oneCity.contactName),
)
// Second click: same column inverts.
const asc = await request.get('/campanha/pessoas?q=C117&sort=lidera&dir=asc')
expect(asc.status()).toBe(200)
const ascHTML = rendered(await asc.text())
expect(ascHTML.indexOf(oneCity.contactName)).toBeLessThan(
ascHTML.indexOf(twoCities.contactName),
)
// Absence facet: only the no-phone person remains.
const absent = await request.get(
'/campanha/pessoas?q=C117&ausencia=sem_contato&sort=lidera&dir=asc',
)
expect(absent.status()).toBe(200)
const absentHTML = rendered(await absent.text())
expect(absentHTML).toContain(noPhoneName)
expect(absentHTML).not.toContain(twoCities.contactName)
expect(absentHTML).toContain('Ausência: Sem contato')
// The union widens: the staff leaderships have no advisors, so they match
// `sem_assessor` — the row hidden by `sem_contato` alone is back in.
const union = await request.get(
'/campanha/pessoas?q=C117&ausencia=sem_contato&ausencia=sem_base&ausencia=sem_assessor&sort=lidera&dir=asc',
)
expect(union.status()).toBe(200)
const unionHTML = rendered(await union.text())
expect(unionHTML).toContain(noPhoneName)
expect(unionHTML).toContain(twoCities.contactName)
expect(unionHTML).toContain('Ausência: Sem contato')
expect(unionHTML).toContain('Ausência: Sem base')
expect(unionHTML).toContain('Ausência: Sem assessor')
})
})

View file

@ -1,121 +0,0 @@
import { expect, test, waitForStreamSettled } from './fixtures/campaignE2EFixtures.js'
/**
* C142 — advisor permission profiles respect the UI surface:
*
* 1. `somente_leitura` editing: no write controls on any surface, FAB absent.
* 2. `carteira` editing: write scoped to portfolio; rows outside read-only.
* 3. `tudo` editing: full write access everywhere.
*
* The spec reuses the shared `campaignFixture` which provisions a fresh
* browser context per test — no cross-test cookie leaks.
*/
test.describe('Advisor permission profiles (C142)', () => {
test.describe('somente_leitura — read-only across all surfaces', () => {
test('municipality list has no write controls and the FAB is absent', async ({
campaign,
page,
}) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'somente_leitura',
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/municipios')
await page.waitForLoadState('networkidle')
// FAB must not be present on the municipality list (write surface).
await expect(page.locator('[data-slot="campaign-quick-actions-fab"]')).toBeHidden()
// "Nova demanda" button must not be present (we land on municipios, but
// verify the FAB is absent which covers all surfaces).
// Inspect the action bar for write controls.
const actionButtons = page.getByRole('link', { name: /Nova|Criar|Editar/i })
await expect(actionButtons).toHaveCount(0)
})
test('demands list has no "Nova demanda" button', async ({ campaign, page }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'somente_leitura',
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/demandas')
await page.waitForLoadState('networkidle')
// "Nova demanda" must not be rendered for a somente_leitura advisor.
const novaDemanda = page.getByRole('link', { name: 'Nova demanda' })
await expect(novaDemanda).toBeHidden()
})
test('activities list has no create buttons', async ({ campaign, page }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'somente_leitura',
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/atividades')
await page.waitForLoadState('networkidle')
// The "Planejar giro" and "Nova atividade" buttons must be absent.
const createButtons = page.getByRole('button', { name: /Planejar|Nova atividade/i })
await expect(createButtons).toHaveCount(0)
})
test('supporters list has no "Novo" or "Importar CSV" buttons', async ({ campaign, page }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'somente_leitura',
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/apoiadores')
await page.waitForLoadState('networkidle')
const novoButton = page.getByRole('link', { name: 'Novo' })
await expect(novoButton).toBeHidden()
})
})
test.describe('carteira editing — write scoped to portfolio', () => {
test('rows outside the portfolio are read-only', async ({ campaign, page }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'carteira',
editing: 'carteira',
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/municipios')
await page.waitForLoadState('networkidle')
// C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell;
// the FAB lives in the streamed shell and won't be present until the
// `S:` copies settle.
await waitForStreamSettled(page)
// The FAB should be present (carteira scope = write allowed).
await expect(page.locator('[data-slot="campaign-quick-actions-fab"]')).toBeVisible()
})
})
test.describe('tudo editing — full write access', () => {
test('write controls are present', async ({ campaign, page }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'tudo',
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/municipios')
await page.waitForLoadState('networkidle')
// C106 — same shell-settle requirement as the carteira sibling.
await waitForStreamSettled(page)
// The FAB must be present for a tudo advisor.
await expect(page.locator('[data-slot="campaign-quick-actions-fab"]')).toBeVisible()
})
})
})

View file

@ -0,0 +1,124 @@
import { expect, rendered, test } from './fixtures/campaignHttpTest.js'
/**
* OPS87 — `campaignPermissionProfile` migrated wholesale to the browserless
* HTTP mode (same server, same database, real HTTP, no browser). Every one of
* the family's assertions was already server-side: the FAB mount decision and
* the write buttons are computed in the page/layout (C142 gates
* `canCreate`/`editingScope` in RSC — AdvisorEditingScope `somente_leitura`
* → `none`), so presence/absence in the rendered HTML IS the contract. The
* browser spec's `waitForFunction` on the `S:` stream settle was browser-DOM
* stabilization, not an assertion — the raw HTML carries the committed shell,
* so nothing replaces it.
*
* The browser file was deleted: all 6 tests live here with their original
* names (1:1 auditability via `git log`/`-g`). The client-failure guard stays
* exclusive to browser specs (OPS35).
*
* Browser twin: none (deleted) — tests/e2e/campaignPermissionProfile.e2e.spec.ts.
*/
test.describe('Advisor permission profiles (C142) (HTTP)', () => {
test.describe('somente_leitura — read-only across all surfaces', () => {
test('municipality list has no write controls and the FAB is absent', async ({
campaign,
campaignRequest,
}) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'somente_leitura',
})
const request = await campaignRequest(user, user.password)
const response = await request.get('/campanha/municipios')
expect(response.status()).toBe(200)
const html = rendered(await response.text())
// FAB must not be rendered on the municipality list (write surface).
expect(html).not.toContain('data-slot="campaign-quick-actions-fab"')
// No write-control link names on the page ("Nova…", "Criar…", "Editar…"
// — the browser spec matched them by role=link, the HTML twin pins the
// same words in element text nodes).
expect(html).not.toMatch(/>[^<>]{0,80}(?:Nova|Criar|Editar)/)
})
test('demands list has no "Nova demanda" button', async ({ campaign, campaignRequest }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'somente_leitura',
})
const request = await campaignRequest(user, user.password)
const response = await request.get('/campanha/demandas')
expect(response.status()).toBe(200)
// "Nova demanda" is gated server-side (`canCreate`) — never rendered
// for a somente_leitura advisor.
expect(rendered(await response.text())).not.toContain('Nova demanda')
})
test('activities list has no create buttons', async ({ campaign, campaignRequest }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'somente_leitura',
})
const request = await campaignRequest(user, user.password)
const response = await request.get('/campanha/atividades')
expect(response.status()).toBe(200)
const html = rendered(await response.text())
// "Planejar giro" (server link) and "Nova atividade" (client host, but
// its inclusion is server-gated) must both be absent.
expect(html).not.toContain('Planejar giro')
expect(html).not.toContain('Nova atividade')
})
test('supporters list has no "Novo" or "Importar CSV" buttons', async ({
campaign,
campaignRequest,
}) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'somente_leitura',
})
const request = await campaignRequest(user, user.password)
const response = await request.get('/campanha/apoiadores')
expect(response.status()).toBe(200)
const html = rendered(await response.text())
// The labels the browser spec pinned by role=link — never the hrefs
// (a route rename must not let a leaking write control pass green).
expect(html).not.toMatch(/>\s*Novo</)
expect(html).not.toContain('Importar CSV')
})
})
test.describe('carteira editing — write scoped to portfolio', () => {
test('rows outside the portfolio are read-only', async ({ campaign, campaignRequest }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'carteira',
editing: 'carteira',
})
const request = await campaignRequest(user, user.password)
const response = await request.get('/campanha/municipios')
expect(response.status()).toBe(200)
// The FAB should be rendered (carteira scope = write allowed).
expect(rendered(await response.text())).toContain('data-slot="campaign-quick-actions-fab"')
})
})
test.describe('tudo editing — full write access', () => {
test('write controls are present', async ({ campaign, campaignRequest }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'tudo',
})
const request = await campaignRequest(user, user.password)
const response = await request.get('/campanha/municipios')
expect(response.status()).toBe(200)
// The FAB must be rendered for a tudo advisor.
expect(rendered(await response.text())).toContain('data-slot="campaign-quick-actions-fab"')
})
})
})

View file

@ -1,4 +1,4 @@
import { expect, test } from './fixtures/campaignHttpTest.js'
import { assertLeaderRedirect, expect, rendered, test } from './fixtures/campaignHttpTest.js'
/**
* OPS35 — `campaignTerritories` migrated to the browserless HTTP mode: same
@ -20,8 +20,6 @@ import { expect, test } from './fixtures/campaignHttpTest.js'
* state.
*/
const rendered = (html: string) => html.replaceAll('<!-- -->', '')
test.describe('Territórios de Identidade (HTTP)', () => {
test('staff sorts, filters and opens the municipality queue for a territory', async ({
campaign,
@ -140,15 +138,7 @@ test.describe('Territórios de Identidade (HTTP)', () => {
// redirect beats the stream it is a real 3xx (dev 307 / prod 308). Both
// are the server contract the browser follows — assert the redirect
// target, never pin the transport.
const direct = await request.get('/campanha/territorios', { maxRedirects: 0 })
expect([200, 307, 308]).toContain(direct.status())
if (direct.status() === 200) {
expect(await direct.text()).toContain(
'http-equiv="refresh" content="1;url=/campanha/meus-contatos"',
)
} else {
expect(direct.headers()['location']).toMatch(/\/campanha\/meus-contatos$/)
}
await assertLeaderRedirect(request, '/campanha/territorios')
// The leader home renders no way in.
const home = await request.get('/campanha/meus-contatos')

View file

@ -110,3 +110,32 @@ export const test = base.extend<CampaignHttpTestFixtures>({
})
export { expect }
/**
* OPS35 → OPS87 — React's streaming inserts `<!-- -->` text-node separators
* between adjacent server-rendered nodes: transport, not content. Every text
* assertion over the rendered HTML runs through this strip. Was local to
* `campaignTerritoriesHttp` until the second HTTP family needed it (OPS87) —
* the base fixture is the natural owner of the HTTP-mode parsing utility.
*/
export const rendered = (html: string) => html.replaceAll('<!-- -->', '')
/**
* The route-gate redirect contract (OPS35 → OPS87, 4 call sites): `noLeader`/
* `staff` gates throw `redirect()` deep in the page, after the layout has
* started streaming — so the document usually answers 200 carrying Next's
* route-redirect meta tag instead of a 3xx; when the redirect beats the
* stream it is a real 3xx (dev 307 / prod 308). Both are the server contract
* the browser follows — assert the redirect TARGET, never pin the transport.
*/
export const assertLeaderRedirect = async (request: APIRequestContext, path: string) => {
const direct = await request.get(path, { maxRedirects: 0 })
expect([200, 307, 308]).toContain(direct.status())
if (direct.status() === 200) {
expect(await direct.text()).toContain(
'http-equiv="refresh" content="1;url=/campanha/meus-contatos"',
)
} else {
expect(direct.headers()['location']).toMatch(/\/campanha\/meus-contatos$/)
}
}