mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:05:17 +00:00
test(e2e): OPS87 — migrar asserções de servidor para HTTP sem browser (Concepts + PermissionProfile + People) + medir −65% (Closes #833)
This commit is contained in:
parent
3d4ddc08e3
commit
f1af316f71
12 changed files with 908 additions and 395 deletions
1
docs/changelog/2026-08-24-ops87.md
Normal file
1
docs/changelog/2026-08-24-ops87.md
Normal 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.
|
||||
173
docs/plans/ops87-piramide-e2e-http-sem-browser-impl.md
Normal file
173
docs/plans/ops87-piramide-e2e-http-sem-browser-impl.md
Normal 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).
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
138
scripts/measure-e2e-family.mjs
Normal file
138
scripts/measure-e2e-family.mjs
Normal 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)`)
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
78
tests/e2e/campaignConceptsHttp.e2e.spec.ts
Normal file
78
tests/e2e/campaignConceptsHttp.e2e.spec.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
311
tests/e2e/campaignPeopleHttp.e2e.spec.ts
Normal file
311
tests/e2e/campaignPeopleHttp.e2e.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
124
tests/e2e/campaignPermissionProfileHttp.e2e.spec.ts
Normal file
124
tests/e2e/campaignPermissionProfileHttp.e2e.spec.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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$/)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue