diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index f82eb61f..8351156a 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -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:` 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 ` 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 ` (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).
diff --git a/docs/plans/e2e-journeys-seriais-arquivos-pesados-impl.md b/docs/plans/e2e-journeys-seriais-arquivos-pesados-impl.md
new file mode 100644
index 00000000..811d97ae
--- /dev/null
+++ b/docs/plans/e2e-journeys-seriais-arquivos-pesados-impl.md
@@ -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` — `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` — `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
diff --git a/docs/plans/prewarm-rotas-home-search-mudar-tendencia.md b/docs/plans/prewarm-rotas-home-search-mudar-tendencia.md
new file mode 100644
index 00000000..cf49f955
--- /dev/null
+++ b/docs/plans/prewarm-rotas-home-search-mudar-tendencia.md
@@ -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.
diff --git a/tests/e2e/campaignBottomNav.e2e.spec.ts b/tests/e2e/campaignBottomNav.e2e.spec.ts
index 180612d3..aacd9c42 100644
--- a/tests/e2e/campaignBottomNav.e2e.spec.ts
+++ b/tests/e2e/campaignBottomNav.e2e.spec.ts
@@ -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"]')
diff --git a/tests/e2e/campaignHomeActions.e2e.spec.ts b/tests/e2e/campaignHomeActions.e2e.spec.ts
index ab2c7dc9..e73a8003 100644
--- a/tests/e2e/campaignHomeActions.e2e.spec.ts
+++ b/tests/e2e/campaignHomeActions.e2e.spec.ts
@@ -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({
diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts
index d865848b..db70dbc0 100644
--- a/tests/e2e/campaignMunicipalities.e2e.spec.ts
+++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts
@@ -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()
diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts
index 64f82b26..d43e9c09 100644
--- a/tests/e2e/fixtures/campaignE2EFixtures.ts
+++ b/tests/e2e/fixtures/campaignE2EFixtures.ts
@@ -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
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 =
}).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 => {
+ 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 => {
+ // `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 => {
+ 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
payload: Payload
+ sessionFor: (context: BrowserContext, user: CampaignSessionUser) => Promise
transaction: (
operation: (payload: Payload, req: TransactionRequest) => Promise,
) => Promise
@@ -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,