chore(OPS36): journeys com sessão compartilhada nos e2e pesados — seed do campaign-token via payload.login sem login de browser

- Sessão por arquivo: coordinator/leader compartilhados por worker (ownership
  própria, cleanup no afterAll); beforeEach semeia o cookie campaign-token no
  context fresh de cada teste; advisors permanecem por teste (OPS29/B176)
- Premissa da intenção corrigida por medição: getPayload é memoizado por
  worker (364 ms cold, 0 ms warm); o overhead real era login de browser
  (3.6 s quente / 30.3 s 1º hit vs 112-134 ms de seed) + criação de usuário
- Sem mode: 'serial' (divergência declarada): seed por cookie dispensa o
  reuso de contexto e evita skips em cascata no relatório
- Endurece 7 interações client-side com toPass (classe B13/B17 exposta pela
  remoção do warm-up do login)
- Medição antes/depois registrada no CHANGELOG; débito OPS39 (prewarm de
  home-search/mudar-tendencia) registrado com depends no OPS30
This commit is contained in:
Francisco Solla 2026-08-11 07:41:14 -03:00
parent bfe3bf6b19
commit 07dd68511f
7 changed files with 469 additions and 260 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-10):** **OPS36** (Issue #601) — journeys com **sessão compartilhada** nos 3 arquivos e2e pesados (`campaignHomeActions` 17, `campaignMunicipalities` 20, `campaignBottomNav` 9 testes): o overhead repetido por teste **amortizado de verdade**, não o que a intenção supunha. **Premissa da intenção corrigida por medição:** `getPayload` NÃO é pago por teste — Payload 3.82 memoiza a instância em `global._payload` keyed por `options.key` (medido: 364 ms na 1ª chamada do worker, 0 ms nas seguintes). O custo real era **o login de browser + a criação de usuário por teste** (bcrypt): login isolado medido em **3.6 s (quente) / 30.3 s (1º hit)** vs **112–134 ms** do `payload.login` de seed — ~32×. **Mecanismo:** `beforeAll` por arquivo cria UM coordinator + UM leader (instância `CampaignE2EOwnership` própria, cleanup em `afterAll`) e minta o token via `payload.login` (a MESMA via do login real — session row + JWT que o app valida); `beforeEach` injeta o cookie `campaign-token` (path `/campanha`, httpOnly, sameSite lax) no context fresh de cada teste — helpers `mintCampaignSession`/`seedCampaignSession`/`createCampaignOwnership` no fixture (donos novos de um concern existente). **Divergências declaradas da intenção:** (1) **sem `mode: 'serial'`** — serial existia para reusar o contexto do browser; com seed por cookie o reuso é desnecessário e o serial skipa N+1..k em falha (perde diagnóstico — anti-goal "mega-teste" da própria intenção); (2) storageState rejeitado (file race entre workers + `test.use` em tempo de definição); (3) **login UI sai dos 46 testes** — caminho permanece coberto por `campaignAuth.e2e.spec.ts` + journey mista t9 (advisor→leader, mantém browser login); advisors **nunca** compartilhados (OPS29 exige portfólio vazio; B176 atribui portfólio). **Flake classe exposta e endurecida:** sem o warm-up incidental do login, clicks de estado client-side (drawer/sheet/overlay) passaram a correr contra a hidratação — a classe B13/B17 da casa; 7 interações ganharam o padrão `toPass` (B70 add-liderança, Mais drawer, B42 trend/C109, B193 scenario, FAB×3) sem enfraquecer asserção. **Medição antes/depois:** baseline 29/49 passou em 10.0 m a load ~55 (20 falhas = classe de carga/restart de Postgres por outro worktree — OPS37; A/B no código original: B97 falha identicamente na mesma carga, e o conjunto falhante casa o do baseline); com o seed, runs limpas: homeActions 18/19 (2.6 m), interações endurecidas 8/10 (falhas residuais = render lento sob load, classe pré-existente comprovada). Sem migration/access/Consent/UI. Intenção [`e2e-journeys-seriais-arquivos-pesados.md`](plans/e2e-journeys-seriais-arquivos-pesados.md); impl [`e2e-journeys-seriais-arquivos-pesados-impl.md`](plans/e2e-journeys-seriais-arquivos-pesados-impl.md).
**Recently resolved (2026-08-10):** **B195** (Issue #591) — wizard "Registrar pedido" completo (A5, adiado no B85): o passo final saiu do stub e virou formulário funcional no shell do wizard — município no header do app (chrome `municipalityLabel`), **sem título grande de passo** (`stepTitle={null}` + `contentFocus="none"`), Tipo → Atividade (opcional) → **campo único** "O que você precisa?"; salvar ("Abrir demanda") → toast + retorno à origem (`returnPath`). **Mudança de modelo da demanda:** o cadastro (wizard e `/demandas/nova`, sem divergência) tem UM campo de texto (`description` required, min 2, max 4000 — constante `CAMPAIGN_DEMAND_BODY_MAX_LENGTH`); o **título virou derivado por IA** (`src/utilities/ai/campaignDemandTitle.ts` — `generateText` DeepSeek `deepseek-v4-flash` como o Sollinha, timeout 4s, sem key → `null`, prompt com rótulo do tipo) com **fallback = texto truncado** (`src/lib/demandTitle.ts`, puro: colapsa whitespace, ≤160) no create e **keep-previous na edição** (novo `updateCampaignDemandRecord` + editor inline no detalhe — recalc só quando a descrição muda; slug preservado pela invariante canônica); `campaignDemandCreateSchema` perdeu `title` (só os drafts de demanda da atividade mantêm título digitado — vertical fora de escopo, schema desacoplado). **Unicidade de slug defensiva só no create das forms** (`ensureUniqueDemandSlug`, opt-in `context.campaignDemandUniqueSlug` fail-closed em transação ativa — precedente `skipContactPhoneInvariant`): probe único da janela `-2…-20` + advisory lock `campaign-demand-slug:<base>` contra race; activity drafts/admin mantêm a constraint estrita (contrato de rollback C90 pinado em teste). Page: branch `register-demand` no router + **staff gate ANTES do load do escopo** (`gate: 'staff'` → líder vai a `/campanha/contatos`, B43 — o 403 do scope load pré-existente virava erro não determinístico; stub `WizardMunicipalitySelectedStub` removido, placeholder "em breve" apagado). **Bug latente corrigido no hook compartilhado** `useCampaignFormSuccessToast` (dependência `[state.message, state.status]` → `[state]`: segundo save com a mesma mensagem — caso do editor — não disparava toast/refresh). Copy compartilhada em `schemas/campaignDemand.ts`; parser único `parseCampaignDemandCreateFormData` (`utilities/demand/`). **Sem migration** (título continua required, preenchido pelo record). Testes: unit do fallback/limites exatos; int (create deriva, colisão `-2`/`-3`, min 2, update keep-previous/skip, leader edit bloqueado, pin "sem `DEEPSEEK_API_KEY`" no `beforeAll`); e2e novo `campaignRegisterDemand` (fluxo completo com título na lista, back-nav à busca, lockdown de líder). Intenção [`registrar-pedido-wizard.md`](plans/registrar-pedido-wizard.md); impl [`registrar-pedido-wizard-impl.md`](plans/registrar-pedido-wizard-impl.md).
**Recently resolved (2026-08-10):** **OPS33** (Issue #595) — `worktree next` **claima deterministicamente** antes de criar o worktree: mesma fila/ordem e lock otimista do `pnpm agent:claim`, com o claim extraído para `scripts/lib/agent-github.mjs` (`claimIssue` — lock + flip + comentário; `claimBriefLines` — brief compartilhado; `claimQueueEntry` — shape para o reopen; `claimTargetVerdict` — decisão pura testada) e o `agent-claim.mjs` virou CLI fino com a **mesma saída**; claim falhou (fila vazia/race/`--issue` inválido) → motivo e saída **sem worktree órfão** (branch validada antes do claim; hint de falha pós-claim). `--issue <N>` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; recupera também falha pós-claim); a saída imprime o brief e avisa "já claimada — NÃO rodar `pnpm agent:claim`". O **launch** do terminal agora envia `/work-issue --issue <N>` (prompt citado na diretiva) e a função `worktree()` de `.agents/shell/worktree.sh` passou a **tokenizar a diretiva via xargs** (quote-aware, nunca eval) — o valor do `--prompt` carrega espaço. `--stay` suprime cd+launch mas o claim acontece; o comando `/worktree` roda o mesmo script (claim incluso, sem launch). Guard `-B` contra reset de commits de sessão no reopen. Pool intacto (`claimIssueForPool` coordenado). Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue` (sai "read-only"), AGENT-OPS. Sem migration/dados/access/UI. Intenção [`worktree-next-claima-issue-deterministicamente.md`](plans/worktree-next-claima-issue-deterministicamente.md); impl [`worktree-next-claima-issue-deterministicamente-impl.md`](plans/worktree-next-claima-issue-deterministicamente-impl.md).

View file

@ -0,0 +1,121 @@
# Impl: E2E — journeys com sessão compartilhada nos arquivos pesados (amortizar overhead por teste)
Status: em execução
Atualizado em: 2026-08-10
Issue: #601
Intenção: docs/plans/e2e-journeys-seriais-arquivos-pesados.md
Appetite restante: ~1 dia eng (herdado)
## Leitura da intenção
- **Outcome:** wall time dos 3 arquivos (`campaignHomeActions` 17, `campaignMunicipalities` 20, `campaignBottomNav` 9) cai de forma **mensurável**; cada teste continua um teste separado no relatório (falha localizada, retry por teste); **zero asserções removidas/enfraquecidas**; classe de flake por interação não piora; decisão por arquivo com medição.
- **O que NÃO negociar:** granularidade por teste (retry/diagnóstico); fixtures **por teste** (ownership por runID; cleanup por teste); paralelismo entre arquivos; nenhum caminho deixa de ser testado.
- **O que reavaliar:** (1) a premissa "cada teste paga `getPayload`" — **falsa**: Payload 3.82 memoiza a instância em `global._payload` keyed por `options.key`; medido: 364 ms na 1ª chamada do worker, **0 ms** nas seguintes (fixture já amortiza). (2) "login no `beforeAll` + reuso do contexto no modo serial" — substituível por **seed de sessão** (mesma via de login do app, sem browser), que preserva contexto fresh por teste e dispensa o serial. (3) "grupos = describes atuais" — na prática os describes misturam papéis (ex.: B47 tem coordinator, leader e advisor no MESMO describe); o agrupamento viável é **por papel**.
## Medições (baseline, 2026-08-10, máquina sob load ~55, 2 workers, dev server)
| Fase | Medido |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `getPayload` cold | 364 ms (1ª por worker) |
| `getPayload` warm | 0 ms (memoizado) |
| Teste simples (coordinator + login + 1 assert) | **1.9–2.3 s** — login ≈ metade |
| Teste wizard / municípios | 5–11 s (navegações RSC, não amortizáveis) |
| Baseline dos 3 arquivos | 29 passou / **20 falhou** em 10.0 m; falhas concentradas no **login** (timeout no fill de "E-mail ou celular") |
Conclusão: o overhead amortizável real é **o login de browser + a criação de usuário por teste** (bcrypt) — não o Payload. Sob carga, o login é também o primeiro a estourar o timeout de 60 s: removê-lo do caminho crítico de 46 testes reduz a superfície de flake **e** o wall time.
## Abordagem recomendada
**Sessão compartilhada por grupo de papel, semeada por cookie — sem browser login, sem modo serial.**
```mermaid
flowchart LR
A[describe por papel] --> B[beforeAll: ownership do grupo + cria usuário + payload.login UMA vez]
B --> C[beforeEach: addCookies campaign-token no context fresh do teste]
C --> D[testes: corpo sem login, asserts idênticos, fixtures/cleanup por teste]
D --> E[afterAll: cleanup do grupo]
```
**Opções consideradas:**
- **A — Seed de sessão (recomendada):** `beforeAll` do grupo cria o usuário (numa instância de ownership do grupo) e minta o token via `payload.login` — **a mesma via do login real** (cria a session row, respeita `loginWithUsername`, retorna o token que o app valida). `beforeEach` injeta `campaign-token` no context do teste (fresh por teste, como hoje). Login de browser desaparece dos testes compartilhados; `describe.configure({ mode: 'serial' })` **não é necessário** (a sessão não é reusada por contexto — é semeada por cookie).
- **B — Serial + storageState (a letra da intenção):** um `beforeAll` faz o login de browser UMA vez e grava `storageState`; `test.use({ storageState })`; describes em `mode: 'serial'`. **Rejeitada** porque: (1) falha em teste N **skipa N+1..k** do grupo no relatório — perde diagnóstico, exatamente o anti-goal "mega-teste" da intenção em versão diluída; (2) arquivo de estado por worker + `test.use` registrado em tempo de definição cria acoplamento de path e corrida entre workers; (3) retry+serial tem semântica de skip que a intenção diz não piorar.
- **C — Login por `describe` original (a hipótese da intenção):** **impossível como está** — os describes misturam papéis (B47: coordinator/leader/advisor no mesmo bloco); um login por describe exigiria um usuário por describe e quebraria o que cada teste assere. Reagrupar por papel É o que a opção A faz, sem pagar serial.
**Rejeitadas em detalhe:** B (skips em cascata + storageState file race) e C (roles mistos no describe).
### Componentes / mudanças
- **`tests/e2e/fixtures/campaignE2EFixtures.ts`** — dois helpers novos (o dono do login já é este módulo):
- `mintCampaignSession(payload, user): Promise<string>` — `payload.login({ collection: 'campaignUser', data: { email, password } })` → token (mesma via do login action; não inventar JWT à mão — a session row do `useSessions` precisa existir).
- `seedCampaignSession(context, baseURL, token): Promise<void>` — `context.addCookies([{ name: CAMPAIGN_TOKEN_COOKIE, value: token, url: baseURL, path: '/campanha', httpOnly: true, sameSite: 'Lax' }])` (espelha `campaignCookieOptions`; sem `secure` em http de teste).
- Objeto `campaign` ganha `sessionFor(context, user)` = mint+seed (para usuários por-teste que continuam existindo: advisors, journeys mistas).
- `CampaignE2EOwnership` **intocado** — o proxy/cleanup por runID continua sendo o contrato por teste.
- **Padrão por grupo (repetido nos 3 arquivos):**
```ts
test.describe('… (papel)', () => {
let groupFixtures: CampaignE2EOwnership
let coordinator: CampaignUser & { password: string }
let sessionToken: string
test.beforeAll(async () => {
const payload = await getPayload({ config }) // memoizado; sem custo
groupFixtures = new CampaignE2EOwnership(payload)
coordinator = await groupFixtures.createCampaignUser('coordinator', {
name: groupFixtures.value('…'),
})
sessionToken = await mintCampaignSession(payload, coordinator)
})
test.beforeEach(async ({ campaign, context }) => {
await seedCampaignSession(context, campaign.baseURL, sessionToken)
})
test.afterAll(async () => {
await groupFixtures.cleanup()
await groupFixtures.expectNoOwnedRows()
})
})
```
Testes do grupo: **removem** `createCampaignUser` + `campaign.login` do corpo; quando o teste usa `coordinator.id` (B193 footer: `author`), usa o do grupo. Asserções intactas.
- **Agrupamento por arquivo (decisão por medição — os 3 têm fração coordinator dominante):**
- `campaignHomeActions`: grupo coordinator (14 testes), grupo leader (2), advisor OPS29 (1 — mantém fresh por teste, exige portfólio vazio; seed via `sessionFor`).
- `campaignMunicipalities`: grupo coordinator (16), advisors fresh por teste (4, incl. 2 OPS29 que exigem portfólio vazio — **não compartilhar advisor**), journey mista advisor→leader (1: **mantém `campaign.login` de browser** — é o único teste dos 3 arquivos que troca de sessão no meio e preserva o caminho de login UI).
- `campaignBottomNav`: grupo coordinator (7), grupo leader (2).
- **Migration:** sem migration.
- **Access / Consent:** sem mudança (fixtures de teste; `ensureLeasedConsent` intocado).
- **UI:** N/A (infra de testes; Impeccable A).
### Decisões de engenharia
- **Mecanismo de sessão:** A (seed por cookie) > B (storageState+serial) > C (login por describe original). Por quê: mesmo ganho de wall time sem os skips em cascata do serial, sem arquivo de estado, sem corrida de workers; contexto fresh por teste preserva o `e2eFailureGuard` e o isolamento de cookies/localStorage como hoje.
- **Onde minta o token:** `beforeAll` do grupo (UMA chamada `payload.login` por worker) — o seed por teste é só `addCookies` (custo ~0). Não re-logar por teste: anula o ganho.
- **Usuários compartilhados:** apenas coordinator/leader (sem estado mutável entre testes — auditado: `payload_preferences` só é escrito pelo B184 'Recorte B184', e nenhum teste posterior do grupo assere ausência de filtro salvo; colunas vêm de cookie por contexto, não do usuário). Advisors **nunca** compartilhados (OPS29 exige portfólio vazio; B176 atribui portfólio).
- **`mode: 'serial'`:** **não usar** — é o mecanismo da intenção para reusar a sessão; com seed a sessão não precisa de reuso de contexto, e o serial piora o relatório em falha (skips). Divergência declarada.
- **Cobertura do login UI:** os 46 testes deixam de exercitar a página de login; o caminho continua coberto por `campaignAuth.e2e.spec.ts` (login/logout/reset/biometria) e pelo journey misto t9 que mantém browser login. Nenhum caminho deixa de ser testado.
## Fases verificáveis
1. **Tracer (mais barato que comprova):** helpers no fixture + converter UM describe (B47 coordinator, 5 testes) → rodar os 5 + verificar sessão válida (`getCampaignUser` aceita o token seedado; asserts passam). Medir o subconjunto estável antes/depois (testes rápidos ~2 s: B47 staff + bottomNav básico).
2. **Conversão dos 3 arquivos** (grupos coordinator/leader; exceções OPS29/t9 conforme acima) — corpo de teste editado no mínimo (só remoção de create+login).
3. **Medição pós + changelog:** re-rodar os mesmos subconjuntos; registrar antes/depois em `docs/CHANGELOG-AGENTS.md` (quota: o outcome da intenção é o registro mensurável).
4. **Gates:** `tsc --noEmit`, `lint`, `format:check`, `knip`, `check:cycles`, unit+int, e2e dos 3 arquivos, `build`. Apagar `tmp-time-payload.ts`.
## Rabbit holes / Não escopo (engenharia)
- Migrar os outros ~29 arquivos para o padrão (decisão por medição, não por princípio — fora da intenção).
- `describe.configure({ mode: 'serial' })` em qualquer outro arquivo.
- storageState/arquivos de estado; reuso de page/context entre testes (acopla estado de browser — anti-goal).
- Ajustar `fullyParallel`/workers/CI (OPS34 é outro item).
- Corrigir flakes pré-existentes (ledger separado; o baseline corrompido por load NÃO entra no changelog como regressão).
## Riscos e mitigação
- **Token seedado rejeitado pelo app** (ex.: sid/session row): mitigado pelo tracer da fase 1 — se `payload.login` não bastar, caímos no storageState (opção B) só para o caso que falhar.
- **Prefs acumuladas no usuário compartilhado** (B184): auditado por teste; nenhuma asserção depende de ausência de filtro salvo; cleanup do grupo apaga usuário+prefs no afterAll.
- **Falha no `beforeAll` do grupo** derruba os testes do grupo com erro claro — mesma superfície de hoje (o login de browser também falhava em cadeia sob carga).
- **Testes que usam `coordinator.id`** (B193 footer): usar o id do grupo — erro de compilação pega qualquer referência órfã a variável removida.
- **Worker crash vaza o usuário do grupo:** mesmo comportamento do estado atual (leak só no banco de teste até o próximo wipe).
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (46 testes, granularidade, zero asserções removidas, medição antes/depois no changelog)
- [x] Invariantes AGENTS/engineering-standards (sem DB de prod; fixtures por teste; ownership intocado)
- [x] Testes de domínio previstos: e2e dos 3 arquivos verdes (nos mesmos workers/CI) + medição registrada

View file

@ -0,0 +1,41 @@
# Prewarm e2e: rotas que o setup spec não cobre flakeiam sob carga em dev
Status: rascunho
Atualizado em: 2026-08-10
Priority: P3
Appetite: ~1 hora eng (duas linhas no setup + medição)
## Intenção
O `setup.e2e.spec.ts` preaquece rotas GET/POST para que o primeiro hit de uma rota
em dev não compile no meio de um teste (o compile frio sob carga estoura o timeout
e aborta fetches em voo — o problema que o próprio setup foi criado para resolver).
Duas rotas usadas pelos journeys dos arquivos pesados ficaram de fora:
- `POST /campanha/home-search` — a busca global (B47/B48/B126): o 1º POST do
worker compila o route handler em dev; sob load ≥40 a resposta passou de 15 s
(B48 falhou 2/2 na sessão OPS36).
- `GET /campanha/acoes/mudar-tendencia` — o wizard de tendência (B97): mesmo
padrão, falhou 2/2 (A/B: também falha no código original — pré-existente).
## Mudança
Adicionar as duas rotas às listas do `setup.e2e.spec.ts` (GETs e POSTs,
respectivamente), seguindo o padrão das entradas existentes (POST sem auth,
`.catch(() => undefined)`). Sem mudança de asserções ou de CI.
## Já resolvido no simplify/critique (não reabrir)
- Nada: achado originado na sessão OPS36.
## Explicitamente fora
- Flakes de render sob carga em geral (B24, B176, demand) — ledger P1 red-e2e.
- OPS30 (Issue #586) é o dono do hardening de prewarm em dev — este item é o
complemento de duas rotas que a medição do OPS36 evidenciou; depende do OPS30
apenas no sentido de não duplicar o trabalho (a Issue nasce `depends: [586]`).
## Gatilho de verificação
- Rodar B48 + B97 em dev a load ≥40 com o prewarm estendido: ambos verdes na
primeira tentativa do worker.

View file

@ -1,14 +1,51 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
import {
createCampaignOwnership,
expect,
mintCampaignSession,
seedCampaignSession,
test,
type CampaignE2EOwnership,
type CampaignSessionUser,
} from './fixtures/campaignE2EFixtures.js'
test.use({ viewport: { width: 390, height: 844 } })
/**
* OPS36 — one shared staff session per worker (see the sibling comment in
* campaignHomeActions.e2e.spec.ts): coordinator + leader are created once,
* each test's fresh context receives the matching `campaign-token` cookie,
* and the group rows are cleaned up in `afterAll`.
*/
let sharedFixtures: CampaignE2EOwnership
let sharedCoordinator: CampaignSessionUser
let coordinatorToken: string
let sharedLeader: CampaignSessionUser
let leaderToken: string
test.beforeAll(async () => {
sharedFixtures = await createCampaignOwnership()
sharedCoordinator = await sharedFixtures.createCampaignUser('coordinator', {
name: sharedFixtures.value('Coordenadora Compartilhada'),
})
coordinatorToken = await mintCampaignSession(sharedFixtures.payload, sharedCoordinator)
sharedLeader = await sharedFixtures.createCampaignUser('leader', {
name: sharedFixtures.value('Liderança Compartilhada'),
})
leaderToken = await mintCampaignSession(sharedFixtures.payload, sharedLeader)
})
test.beforeEach(async ({ campaign, context }) => {
await seedCampaignSession(context, campaign.baseURL, coordinatorToken)
})
test.afterAll(async () => {
if (!sharedFixtures) return
await sharedFixtures.cleanup()
await sharedFixtures.expectNoOwnedRows()
})
test.describe('Mobile bottom nav (B164)', () => {
test('staff sees five-item bottom nav', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Nav'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
test('staff sees five-item bottom nav', async ({ page }) => {
await page.goto('/campanha')
const bottomNav = page.locator('[aria-label="Navegação principal"]')
@ -20,12 +57,7 @@ test.describe('Mobile bottom nav (B164)', () => {
await expect(bottomNav.getByRole('button', { name: 'Mais' })).toBeVisible()
})
test('highlights active item and navigates on tap', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Nav Ativa'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
test('highlights active item and navigates on tap', async ({ page }) => {
await page.goto('/campanha')
const bottomNav = page.locator('[aria-label="Navegação principal"]')
@ -49,47 +81,33 @@ test.describe('Mobile bottom nav (B164)', () => {
)
})
test('Mais opens overflow drawer with secondary destinations', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Drawer'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
test('Mais opens overflow drawer with secondary destinations', async ({ page }) => {
await page.goto('/campanha')
const bottomNav = page.locator('[aria-label="Navegação principal"]')
await bottomNav.getByRole('button', { name: 'Mais' }).click()
// Client-side drawer state — a click that lands before hydration is a
// silent no-op (the B13/B17 flake class; with the shared-session seed the
// page is often the worker's first load, so guard the transition).
await expect(async () => {
await bottomNav.getByRole('button', { name: 'Mais' }).click({ timeout: 1_000 })
await expect(page.locator('[data-slot="drawer-content"]')).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 15_000 })
const drawer = page.locator('[data-slot="drawer-content"]')
await expect(drawer).toBeVisible()
await expect(drawer.getByRole('link', { name: 'Quadro', exact: true })).toBeVisible()
await expect(drawer.getByRole('link', { name: 'Conceitos' })).toBeVisible()
await expect(drawer.getByRole('link', { name: 'Perfil' })).toBeVisible()
await expect(drawer.getByRole('button', { name: 'Sair' })).toBeVisible()
})
test('leader does not see the bottom nav', async ({ campaign, page }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Líder Nav'),
username: phone,
})
await campaign.login(page, phone, leader.password)
test('leader does not see the bottom nav', async ({ campaign, context, page }) => {
await seedCampaignSession(context, campaign.baseURL, leaderToken)
await page.goto('/campanha/contatos')
await expect(page.locator('[aria-label="Navegação principal"]')).toHaveCount(0)
})
test('staff mobile has no sidebar trigger and no nav sheet (C102)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Sem Sheet'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
test('staff mobile has no sidebar trigger and no nav sheet (C102)', async ({ page }) => {
await page.goto('/campanha')
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"]')
@ -101,14 +119,8 @@ test.describe('Mobile bottom nav (B164)', () => {
await expect(page.locator('[aria-label="Navegação principal"]')).toBeVisible()
})
test('leader keeps the sidebar sheet on mobile (C102)', async ({ campaign, page }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Líder Sheet'),
username: phone,
})
await campaign.login(page, phone, leader.password)
test('leader keeps the sidebar sheet on mobile (C102)', async ({ campaign, context, page }) => {
await seedCampaignSession(context, campaign.baseURL, leaderToken)
await page.goto('/campanha/contatos')
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"]')
@ -121,12 +133,7 @@ test.describe('Mobile bottom nav (B164)', () => {
await expect(sheet.getByRole('link', { name: 'Meus contatos' })).toBeVisible()
})
test('hidden on desktop viewport', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Desktop'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
test('hidden on desktop viewport', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 })
await page.goto('/campanha')
@ -136,12 +143,7 @@ test.describe('Mobile bottom nav (B164)', () => {
await expect(page.getByRole('button', { name: /abrir ou fechar/i })).toBeVisible()
})
test('FAB sits above the bottom nav on mobile', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora FAB'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
test('FAB sits above the bottom nav on mobile', async ({ page }) => {
await page.goto('/campanha/municipios')
const fab = page.getByRole('button', { name: 'Ações rápidas' })
@ -155,15 +157,7 @@ test.describe('Mobile bottom nav (B164)', () => {
}
})
test('items breathe below the top edge and labels never overlap (B171)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Estilo'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
test('items breathe below the top edge and labels never overlap (B171)', async ({ page }) => {
await page.goto('/campanha')
const bottomNav = page.locator('[aria-label="Navegação principal"]')

View file

@ -1,7 +1,15 @@
import type { Page } from '@playwright/test'
import { WIZARD_MUNICIPALITY_STEP_TITLE } from '../../src/lib/campaignWizardCopy.js'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
import {
createCampaignOwnership,
expect,
mintCampaignSession,
seedCampaignSession,
test,
type CampaignE2EOwnership,
type CampaignSessionUser,
} from './fixtures/campaignE2EFixtures.js'
import {
assertThreeColumnActionGrid,
collectActionBoundingBoxes,
@ -16,37 +24,56 @@ const staffActionLabels = [
'Ver esquecidos',
] as const
test.describe('Início — busca global (B47)', () => {
test('staff sees campaign search on home', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
/**
* OPS36 — one shared staff session per worker: the coordinator is created once
* (and its token minted once via `payload.login`), every test's fresh context
* receives the `campaign-token` cookie in `beforeEach`, and the group rows are
* cleaned up in `afterAll`. Tests that exercise another role override the
* cookie with their own seed. Assertions are unchanged — only the login
* round trip was removed from the journeys.
*/
let sharedFixtures: CampaignE2EOwnership
let sharedCoordinator: CampaignSessionUser
let coordinatorToken: string
let sharedLeader: CampaignSessionUser
let leaderToken: string
await campaign.login(page, coordinator.email!, coordinator.password)
test.beforeAll(async () => {
sharedFixtures = await createCampaignOwnership()
sharedCoordinator = await sharedFixtures.createCampaignUser('coordinator', {
name: sharedFixtures.value('Coordenadora Compartilhada'),
})
coordinatorToken = await mintCampaignSession(sharedFixtures.payload, sharedCoordinator)
sharedLeader = await sharedFixtures.createCampaignUser('leader', {
name: sharedFixtures.value('Liderança Compartilhada'),
})
leaderToken = await mintCampaignSession(sharedFixtures.payload, sharedLeader)
})
test.beforeEach(async ({ campaign, context }) => {
await seedCampaignSession(context, campaign.baseURL, coordinatorToken)
})
test.afterAll(async () => {
if (!sharedFixtures) return
await sharedFixtures.cleanup()
await sharedFixtures.expectNoOwnedRows()
})
test.describe('Início — busca global (B47)', () => {
test('staff sees campaign search on home', async ({ page }) => {
await page.goto('/campanha')
await expect(page.getByLabel('Buscar na campanha')).toBeVisible()
})
test('leader does not see campaign search on home', async ({ campaign, page }) => {
const { fixtures } = campaign
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança'),
})
await campaign.login(page, leader.email!, leader.password)
test('leader does not see campaign search on home', async ({ campaign, context, page }) => {
await seedCampaignSession(context, campaign.baseURL, leaderToken)
await page.goto('/campanha')
await expect(page.getByLabel('Buscar na campanha')).toHaveCount(0)
})
test('staff focused search hides action strip on input focus (B66)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
test('staff focused search hides action strip on input focus (B66)', async ({ page }) => {
await page.goto('/campanha')
const actionsChrome = page.locator('[data-slot="home-actions-chrome"]')
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
@ -57,16 +84,8 @@ test.describe('Início — busca global (B47)', () => {
await expect(actionsChrome).not.toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
})
test('staff typing in search keeps action strip hidden after debounce', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
test('staff typing in search keeps action strip hidden after debounce', async ({ page }) => {
await page.goto('/campanha')
const actionsChrome = page.locator('[data-slot="home-actions-chrome"]')
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
@ -83,14 +102,11 @@ test.describe('Início — busca global (B47)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
// Claimed, never hardcoded: parallel specs sharing one seeded municipality
// raced each other's mutations on it (miss #73).
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha')
const search = page.getByLabel('Buscar na campanha')
await search.fill(municipality.name)
@ -106,6 +122,7 @@ test.describe('Início — busca global (B47)', () => {
test('staff focused search without curated suggestions shows the honest empty state (OPS29)', async ({
campaign,
context,
page,
}) => {
const { fixtures } = campaign
@ -115,10 +132,10 @@ test.describe('Início — busca global (B47)', () => {
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessora OPS29'),
})
await campaign.login(page, advisor.email!, advisor.password)
await campaign.sessionFor(context, advisor)
// Início renders the suggest payload server-side (initialSuggest), so this
// also covers the SSR path — no POST needed for the empty state (OPS29).
await page.goto('/campanha')
const search = page.getByLabel('Buscar na campanha')
await search.focus()
@ -132,15 +149,9 @@ test.describe('Início — catálogo de ações (B45)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('staff sees six home actions and can open municipalities without coverage', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha')
const actionsRegion = page.getByLabel('Ações rápidas')
await expect(actionsRegion).toBeVisible()
@ -172,13 +183,13 @@ test.describe('Início — catálogo de ações (B45)', () => {
expect(url.searchParams.get('sort')).toBe('votos')
})
test('leader sees two home actions and can open contacts', async ({ campaign, page }) => {
const { fixtures } = campaign
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança'),
})
await campaign.login(page, leader.email!, leader.password)
test('leader sees two home actions and can open contacts', async ({
campaign,
context,
page,
}) => {
await seedCampaignSession(context, campaign.baseURL, leaderToken)
await page.goto('/campanha')
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
await expect(
@ -200,12 +211,8 @@ test.describe('Wizard — busca município (B60)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-votos')
await expect(page.getByLabel('Buscar município')).toBeVisible()
await expect(page.getByRole('region', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
@ -241,12 +248,8 @@ test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/acoes/atualizar-votos?municipio=${municipality.slug}`)
const step = wizardVoteStep(page)
@ -272,12 +275,8 @@ test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`/campanha/acoes/atualizar-votos?municipio=${municipality.slug}&from=${encodeURIComponent(`/campanha/municipios/${municipality.slug}`)}`,
)
@ -298,12 +297,8 @@ test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/acoes/atualizar-votos?municipio=${municipality.slug}`)
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
@ -321,12 +316,8 @@ test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
test('legacy cenario param redirects to canonical URL', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`/campanha/acoes/atualizar-votos?municipio=${municipality.slug}&cenario=pessimistic`,
)
@ -345,21 +336,25 @@ test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
test.describe('Wizard — atualizar liderança (B70)', () => {
test('create leadership, save, and return to origin (B168)', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
const suffix = Date.now().toString().slice(-8).padStart(8, '0')
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/acoes/atualizar-lideranca?municipio=${municipality.slug}`)
await expect(page.getByRole('heading', { name: 'Quem coordena por aqui?' })).toBeVisible({
timeout: 15000,
})
await page.getByRole('button', { name: 'Adicionar liderança' }).click()
await expect(page.getByRole('heading', { name: 'Nova liderança' })).toBeVisible()
// The add tile flips client-side state (`setMode`), so a click that lands
// before hydration is a silent no-op (the B13/B17 flake class) — with the
// shared-session seed the wizard page is often the worker's first load, so
// the retry loop guards the transition instead of the login flow's timing.
await expect(async () => {
await page.getByRole('button', { name: 'Adicionar liderança' }).click({ timeout: 1_000 })
await expect(page.getByRole('heading', { name: 'Nova liderança' })).toBeVisible({
timeout: 5_000,
})
}).toPass({ timeout: 15_000 })
await page.getByLabel('Nome').fill(`Liderança B70 ${suffix}`)
await page.getByLabel('Celular').fill(`719${suffix}`)
@ -382,13 +377,9 @@ test.describe('Wizard — registrar atualização (C87)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/acoes/registrar-atualizacao?municipio=${municipality.slug}`)
await expect(page.getByRole('heading', { name: 'Registrar atualização' })).toBeVisible({
@ -411,12 +402,8 @@ test.describe('Wizard — registrar atualização (C87)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/acoes/registrar-atualizacao?municipio=${municipality.slug}`)
await expect(page.getByRole('heading', { name: 'Registrar atualização' })).toBeVisible({
@ -431,12 +418,8 @@ test.describe('Wizard — registrar atualização (C87)', () => {
test('stale entry param is ignored — never a skip link (B168)', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`/campanha/acoes/registrar-atualizacao?municipio=${municipality.slug}&entry=update-votes`,
)
@ -454,12 +437,8 @@ test.describe('Wizard — mudar tendência (B97 / B168)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/acoes/mudar-tendencia?municipio=${municipality.slug}`)
await expect(page.getByRole('heading', { name: /Tendência/ })).toBeVisible({

View file

@ -9,9 +9,14 @@ import {
import {
campaignPageChrome,
checkRadixWhenHydrated,
createCampaignOwnership,
expect,
expectPostResponse,
mintCampaignSession,
seedCampaignSession,
test,
type CampaignE2EOwnership,
type CampaignSessionUser,
} from './fixtures/campaignE2EFixtures.js'
import {
assertThreeColumnActionGrid,
@ -57,6 +62,36 @@ const ensureWideMunicipalityList = async (page: Page) => {
.toBeGreaterThanOrEqual(78 * 16)
}
/**
* OPS36 — one shared staff session per worker (see the sibling comment in
* campaignHomeActions.e2e.spec.ts): a coordinator is created once and every
* test's fresh context receives its `campaign-token` cookie in `beforeEach`.
* Advisors are never shared (their portfolio must stay empty/fresh — OPS29,
* B176), and the mixed advisor→leader journey below keeps the browser logins.
* The group rows are cleaned up in `afterAll`.
*/
let sharedFixtures: CampaignE2EOwnership
let sharedCoordinator: CampaignSessionUser
let coordinatorToken: string
test.beforeAll(async () => {
sharedFixtures = await createCampaignOwnership()
sharedCoordinator = await sharedFixtures.createCampaignUser('coordinator', {
name: sharedFixtures.value('Coordenadora Compartilhada'),
})
coordinatorToken = await mintCampaignSession(sharedFixtures.payload, sharedCoordinator)
})
test.beforeEach(async ({ campaign, context }) => {
await seedCampaignSession(context, campaign.baseURL, coordinatorToken)
})
test.afterAll(async () => {
if (!sharedFixtures) return
await sharedFixtures.cleanup()
await sharedFixtures.expectNoOwnedRows()
})
/**
* Core municipality-model journeys per role: coordinator strategy editing, advisor
* scoping, staff declare/estimate privacy boundary, leader lockdown, and staff-only
@ -81,16 +116,11 @@ test.describe('Municípios — jornadas por papel', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const password = coordinator.password
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessor Regional'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
@ -145,16 +175,11 @@ test.describe('Municípios — jornadas por papel', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador Combobox'),
})
const password = coordinator.password
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessora Combobox'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
@ -222,16 +247,11 @@ test.describe('Municípios — jornadas por papel', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador Inline'),
})
const password = coordinator.password
const municipality = await fixtures.claimMunicipality()
// Unique per run (uuid + counter), so no eligible account matches it — the
// combobox must offer the create option, not an existing advisor.
const newAdvisorName = fixtures.value('Novo Assessor')
await campaign.login(page, coordinator.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
@ -295,17 +315,12 @@ test.describe('Municípios — jornadas por papel', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador Dobradinhas'),
})
const password = coordinator.password
const municipality = await fixtures.claimMunicipality()
// Unique per run (uuid + counter), so no catalog entry matches it — the
// combobox must offer the create option, not an existing dobradinha.
const deputyName = fixtures.value('Deputado Novo')
const party = 'PCdoB'
await campaign.login(page, coordinator.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
@ -368,9 +383,6 @@ test.describe('Municípios — jornadas por papel', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador Filtros B176'),
})
const municipalityA = await fixtures.claimMunicipality()
const municipalityB = await fixtures.claimMunicipality()
@ -402,7 +414,6 @@ test.describe('Municípios — jornadas por papel', () => {
municipalities: [municipalityB],
})
await campaign.login(page, coordinator.email!, coordinator.password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
@ -445,6 +456,7 @@ test.describe('Municípios — jornadas por papel', () => {
test('B176 — advisor edits Dobradinhas inline within an administered municipality', async ({
campaign,
context,
page,
}) => {
const { fixtures } = campaign
@ -475,7 +487,7 @@ test.describe('Municípios — jornadas por papel', () => {
draft: false,
})
await campaign.login(page, advisor.email!, advisor.password)
await campaign.sessionFor(context, advisor)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
@ -521,14 +533,9 @@ test.describe('Municípios — jornadas por papel', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador Tendência'),
})
const password = coordinator.password
const municipality = await fixtures.claimMunicipality()
const note = fixtures.value('Vereador confirmou apoio local')
await campaign.login(page, coordinator.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
@ -565,13 +572,8 @@ test.describe('Municípios — jornadas por papel', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador Sinal'),
})
const password = coordinator.password
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
@ -711,14 +713,13 @@ test.describe('Municípios — jornadas por papel', () => {
expect(leadership.id).toBeGreaterThan(0)
})
test('advisor opens a demand and decides it', async ({ campaign, page }) => {
test('advisor opens a demand and decides it', async ({ campaign, context, page }) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessor Demandas'),
})
const password = advisor.password
await campaign.payload.update({
collection: 'municipality',
id: municipality.id,
@ -732,7 +733,7 @@ test.describe('Municípios — jornadas por papel', () => {
const demandText = fixtures.value('Carro de som: precisamos para a caminhada de sábado.')
const demandTitle = fallbackDemandTitle(demandText)
await campaign.login(page, advisor.email!, password)
await campaign.sessionFor(context, advisor)
await page.goto(`${campaign.baseURL}/campanha/demandas/nova`)
await page.getByLabel('Município').selectOption({ label: municipality.name })
await page.getByLabel('O que você precisa?').fill(demandText)
@ -763,13 +764,8 @@ test.describe('Municípios — cards no celular (B42)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador Mobile'),
})
const password = coordinator.password
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, password)
const listURL = `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`
await page.goto(listURL)
@ -777,7 +773,17 @@ test.describe('Municípios — cards no celular (B42)', () => {
await expect(card).toBeVisible()
await expect(page.getByRole('link', { name: 'Abrir município' })).toHaveCount(0)
await card.getByRole('button', { name: 'Editar tendência política' }).click()
// Client-side Drawer state — a pre-hydration click is a silent no-op (the
// B13/B17 flake class; with the shared-session seed the card is often the
// worker's first load), so deliver the tap through a retry loop.
await expect(async () => {
await card.getByRole('button', { name: 'Editar tendência política' }).click({
timeout: 1_000,
})
await expect(page.getByRole('dialog', { name: 'Editar tendência' })).toBeVisible({
timeout: 5_000,
})
}).toPass({ timeout: 15_000 })
// B126 FAB overlay is closed by default; scope to the trend dialog.
const drawer = page.getByRole('dialog', { name: 'Editar tendência' })
await expect(drawer).toBeVisible()
@ -813,19 +819,23 @@ test.describe('Municípios — cards no celular (B42)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora C109'),
})
const password = coordinator.password
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, password)
const listURL = `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`
await page.goto(listURL)
const card = page.locator('[data-view="mobile-cards"] article').first()
await expect(card).toBeVisible()
await card.getByRole('button', { name: /^Registrar atualização em/ }).click()
// Pre-hydration click guard (B13/B17 class) — see the sibling comment in
// the B42 trend Drawer test.
await expect(async () => {
await card.getByRole('button', { name: /^Registrar atualização em/ }).click({
timeout: 1_000,
})
await expect(page.getByRole('dialog', { name: 'Registrar atualização' })).toBeVisible({
timeout: 5_000,
})
}).toPass({ timeout: 15_000 })
// The sheet's primary action lives in the custom footer — the two buttons
// the phone thumb expects, rendered inside the shared Drawer.
@ -886,13 +896,6 @@ test.describe('Municípios — filtro e cards sem moldura no celular (B184)', ()
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B184'),
})
const password = coordinator.password
await campaign.login(page, coordinator.email!, password)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
// C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell;
// strict-mode locators below would match BOTH copies while it settles.
@ -978,12 +981,8 @@ test.describe('Municípios — card denso mobile (B193)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B193'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
@ -995,7 +994,15 @@ test.describe('Municípios — card denso mobile (B193)', () => {
.first()
await expect(card).toBeVisible()
await card.getByRole('button', { name: /Editar votos estimados/ }).click()
// Pre-hydration click guard (B13/B17 class) — the sheet is client-side
// state and the card is often the worker's first load with the seeded
// session.
await expect(async () => {
await card.getByRole('button', { name: /Editar votos estimados/ }).click({ timeout: 1_000 })
await expect(page.getByRole('dialog', { name: 'Editar votos estimados' })).toBeVisible({
timeout: 5_000,
})
}).toPass({ timeout: 15_000 })
const drawer = page.getByRole('dialog', { name: 'Editar votos estimados' })
await expect(drawer).toBeVisible()
@ -1019,12 +1026,8 @@ test.describe('Municípios — card denso mobile (B193)', () => {
test('the Nível chip (label included) opens the level sheet', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B193 Nível'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
@ -1047,9 +1050,6 @@ test.describe('Municípios — card denso mobile (B193)', () => {
test('priority município gets the right accent border (B193)', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B193 Prio'),
})
const municipality = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
@ -1058,7 +1058,6 @@ test.describe('Municípios — card denso mobile (B193)', () => {
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
@ -1081,22 +1080,18 @@ test.describe('Municípios — card denso mobile (B193)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B193 Upd'),
})
const withUpdate = await fixtures.claimMunicipality()
await fixtures.payload.create({
collection: 'municipalityUpdate',
data: {
municipality: withUpdate.id,
author: coordinator.id,
author: sharedCoordinator.id,
polarity: 'boa',
body: `Fato de campo B193 em ${withUpdate.name}`,
},
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(withUpdate.name)}`,
)
@ -1151,12 +1146,8 @@ test.describe('Municípios — FAB ações rápidas mobile (B126)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador FAB'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
@ -1166,7 +1157,12 @@ test.describe('Municípios — FAB ações rápidas mobile (B126)', () => {
await expect(fab).toBeVisible()
await expect(page.getByLabel('Buscar na campanha')).toHaveCount(0)
await fab.click()
// Client-side overlay state — pre-hydration click guard (B13/B17 class);
// with the seeded session the list is often the worker's first load.
await expect(async () => {
await fab.click({ timeout: 1_000 })
await expect(page.locator('#CampaignQuickActionsOverlay')).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 15_000 })
const overlay = page.locator('#CampaignQuickActionsOverlay')
await expect(overlay).toBeVisible()
@ -1211,16 +1207,15 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
] as const
test('overlay actions use a 3×2 grid at mobile width (B136)', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador B136'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await page.getByRole('button', { name: 'Ações rápidas' }).click()
// Pre-hydration click guard (B13/B17 class) — same contract as the FAB
// on-load sibling.
await expect(async () => {
await page.getByRole('button', { name: 'Ações rápidas' }).click({ timeout: 1_000 })
await expect(page.locator('#CampaignQuickActionsOverlay')).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 15_000 })
const overlay = page.locator('#CampaignQuickActionsOverlay')
await expect(overlay).toBeVisible()
// The Drawer slides in over ~450ms (`duration-450`); `toBeVisible` fires
@ -1250,9 +1245,6 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador B126'),
})
// The suggest region only renders with curated hits for unrestricted
// roles (B68), so pin one `alta` municipality — deterministic in any
// environment, not only where the projection was imported (OPS29).
@ -1265,11 +1257,15 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
})
fixtures.touchMunicipality(municipality.id)
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await page.getByRole('button', { name: 'Ações rápidas' }).click()
// Pre-hydration click guard (B13/B17 class) — same contract as the FAB
// on-load sibling.
await expect(async () => {
await page.getByRole('button', { name: 'Ações rápidas' }).click({ timeout: 1_000 })
await expect(page.locator('#CampaignQuickActionsOverlay')).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 15_000 })
const overlay = page.locator('#CampaignQuickActionsOverlay')
await expect(overlay).toBeVisible()
// The Drawer/Dialog slides in (~450ms); `toBeVisible` fires mid-flight and
@ -1299,6 +1295,7 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
test('overlay search without curated suggestions shows the honest empty state (OPS29)', async ({
campaign,
context,
page,
}) => {
const { fixtures } = campaign
@ -1310,11 +1307,16 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
name: fixtures.value('Assessor OPS29'),
})
await campaign.login(page, advisor.email!, advisor.password)
await campaign.sessionFor(context, advisor)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await page.getByRole('button', { name: 'Ações rápidas' }).click()
// Pre-hydration click guard (B13/B17 class) — same contract as the FAB
// on-load sibling.
await expect(async () => {
await page.getByRole('button', { name: 'Ações rápidas' }).click({ timeout: 1_000 })
await expect(page.locator('#CampaignQuickActionsOverlay')).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 15_000 })
const overlay = page.locator('#CampaignQuickActionsOverlay')
await expect(overlay).toBeVisible()

View file

@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { sql } from '@payloadcms/db-postgres'
import type { Fixtures, Page } from '@playwright/test'
import type { BrowserContext, Fixtures, Page } from '@playwright/test'
import { getPayload, type CollectionSlug, type Payload, type PayloadRequest } from 'payload'
import { municipalityCatalog } from '../../../src/lib/municipalityCatalog.js'
@ -13,6 +13,12 @@ import { withInviteConsent } from '../../helpers/testDatabaseLease.js'
import { test as base, expect } from './e2eTest.js'
const defaultBaseURL = 'http://localhost:3000'
/**
* The campaign session cookie name is owned by `src/utilities/campaignAuth.ts`
* (CAMPAIGN_TOKEN_COOKIE) — the seeded cookie below must match it.
*/
const CAMPAIGN_TOKEN_COOKIE = 'campaign-token'
type TransactionRequest = Pick<PayloadRequest, 'transactionID'>
type OwnedCollection =
| 'users'
@ -62,7 +68,7 @@ const deletionOrder: OwnedCollection[] = [
* through the proxy: use `ensureLeasedConsent` (testDatabaseLease.ts) so cleanup
* cannot rob a parallel spec of the shared row.
*/
class CampaignE2EOwnership {
export class CampaignE2EOwnership {
readonly payload: Payload
readonly runID = randomUUID()
private counter = 0
@ -516,11 +522,71 @@ export const expectCampaignBiometricsReady = async (page: Page): Promise<void> =
}).toPass({ timeout: 15_000 })
}
export type CampaignSessionUser = CampaignUser & { password: string }
/**
* OPS36 — session seeding. The shared-session journeys skip the browser login
* round trip: `mintCampaignSession` goes through `payload.login` (the same
* Local API path the real login action uses, so the session row and JWT are
* exactly what the app validates) and `seedCampaignSession` injects the
* resulting `campaign-token` cookie into a test context. The cookie mirrors
* `campaignCookieOptions` in `src/utilities/campaignAuth.ts` (path `/campanha`,
* httpOnly, sameSite lax; no `secure` on the http test origin).
*/
export const mintCampaignSession = async (
payload: Payload,
user: CampaignSessionUser,
): Promise<string> => {
if (!user.email) {
throw new Error('Campaign session seeding requires a user with an email.')
}
const { token } = await payload.login({
collection: 'campaignUser',
data: { email: user.email, password: user.password },
depth: 0,
})
if (!token) {
throw new Error('Campaign session seeding failed to obtain a session token.')
}
return token
}
export const seedCampaignSession = async (
context: BrowserContext,
baseURL: string,
token: string,
): Promise<void> => {
// `addCookies` accepts either `url` or `domain`+`path`, never both — and the
// campaign cookie must live on `/campanha`, so the domain/path pair is used.
await context.addCookies([
{
name: CAMPAIGN_TOKEN_COOKIE,
value: token,
domain: new URL(baseURL).hostname,
path: '/campanha',
httpOnly: true,
sameSite: 'Lax',
},
])
}
/**
* One ownership instance (own `runID`, own cleanup) wrapping the memoized
* in-process Payload — the describe-level counterpart of the per-test
* `campaign` fixture, for users shared across a journey group.
*/
export const createCampaignOwnership = async (): Promise<CampaignE2EOwnership> => {
assertTestDatabase(process.env.DATABASE_URL)
const payload = await getPayload({ config })
return new CampaignE2EOwnership(payload)
}
export type CampaignE2EFixture = {
baseURL: string
fixtures: CampaignE2EOwnership
login: (page: Page, identifier: string, password: string) => Promise<void>
payload: Payload
sessionFor: (context: BrowserContext, user: CampaignSessionUser) => Promise<void>
transaction: <Result>(
operation: (payload: Payload, req: TransactionRequest) => Promise<Result>,
) => Promise<Result>
@ -562,6 +628,10 @@ export const campaignFixture: CampaignE2EFixtureValue = async ({}, runFixture) =
await page.waitForURL(`${baseURL}/campanha`)
},
payload: fixtures.payload,
sessionFor: async (context, user) => {
const token = await mintCampaignSession(payload, user)
await seedCampaignSession(context, baseURL, token)
},
transaction: (operation) =>
withPayloadTransaction(payload, ({ req }) => operation(fixtures.payload, req)),
withInviteConsent,