From 07dd68511f1931dde6ec15bc1a19c7d58dc168fb Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 07:41:14 -0300 Subject: [PATCH] =?UTF-8?q?chore(OPS36):=20journeys=20com=20sess=C3=A3o=20?= =?UTF-8?q?compartilhada=20nos=20e2e=20pesados=20=E2=80=94=20seed=20do=20c?= =?UTF-8?q?ampaign-token=20via=20payload.login=20sem=20login=20de=20browse?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/CHANGELOG-AGENTS.md | 2 + ...-journeys-seriais-arquivos-pesados-impl.md | 121 +++++++++++ ...ewarm-rotas-home-search-mudar-tendencia.md | 41 ++++ tests/e2e/campaignBottomNav.e2e.spec.ts | 128 ++++++------ tests/e2e/campaignHomeActions.e2e.spec.ts | 173 +++++++--------- tests/e2e/campaignMunicipalities.e2e.spec.ts | 190 +++++++++--------- tests/e2e/fixtures/campaignE2EFixtures.ts | 74 ++++++- 7 files changed, 469 insertions(+), 260 deletions(-) create mode 100644 docs/plans/e2e-journeys-seriais-arquivos-pesados-impl.md create mode 100644 docs/plans/prewarm-rotas-home-search-mudar-tendencia.md 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,