Merge pull request #621 from fsolla/OPS35-e2e-full-stack-sem-browser-paradigma-http-para-asserco

OPS35 — E2E full-stack sem browser (paradigma HTTP) para asserções de servidor
This commit is contained in:
Francisco Solla 2026-08-10 23:01:20 +00:00 committed by GitHub
commit 07479f1762
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 514 additions and 397 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,121 @@
# Impl: E2E full-stack sem browser (paradigma HTTP) para asserções de servidor
Status: aprovado
Atualizado em: 2026-08-10
Issue: #600
Intenção: docs/plans/e2e-full-stack-sem-browser.md
Appetite restante: herdado (~1–1,5 dia eng; este plano corta para a prova do paradigma + 1 família + medição, como a intenção manda)
## Leitura da intenção
- **Outcome:** um modo de spec e2e que roda no job e2e existente (mesmo servidor + DB reais) **sem lançar browser**, com sessão real de campanha; a primeira família migra com asserções equivalentes; o ganho de tempo por família é medido e registrado no changelog; o guard de falhas client permanece exclusivo do browser.
- **O que NÃO negociar:** asserções migradas 1:1 em cobertura (nada removido sem equivalente); sessão real (`campaign-token`), sem duplicar lógica de auth no teste; sem chamar server actions por HTTP cru (protocolo RSC); sem framework de teste novo.
- **O que reavaliar:** (1) a recomendação da intenção para login "browser real por run → storageState" — esta análise opta por login REST do Payload, 100% sem browser; (2) a nomenclatura `*.http.e2e.spec.ts` da intenção **não casa** com o `testMatch` do projeto (`/campaign.*\.e2e\.spec\.ts/` — descoberta de execução) — o marcador vira sufixo `Http` mantendo o prefixo `campaign`; (3) a escolha da primeira família (a intenção sugeria "uma família pequena"; análise: `campaignTerritories` é a única cujas 4 specs são ~100% servidor).
## Abordagem recomendada
```mermaid
flowchart LR
Spec[spec campaignTerritoriesHttp.e2e.spec.ts] --> Base[campaignHttpTest.ts<br/>test cru @playwright/test]
Base --> Fixture[campaign fixture reusado<br/>ownership/cleanup in-process]
Base --> Login[campaignRequest fixture<br/>POST /api/campaignUser/login → token]
Login --> Ctx[request.newContext<br/>storageState cookie campaign-token path /campanha]
Ctx --> GETs[GET rotas /campanha/* → 200 + HTML]
GETs --> Assert[asserções server-rendered<br/>caption sort, footer, âncoras, headers, redirect]
```
**Opções consideradas:** A | B | C (sessão) e A | B (nomenclatura)
**Recomendação:** login REST + mesma família no projeto `campaign` — porque exercita o endpoint real do app sem browser nenhum, e a sessão via cookie canônico `campaign-token` é exatamente o que `getCampaignUser()` lê (nada duplicado, nada simulado).
**Rejeitadas:** ver Decisões de engenharia.
### Decisões de engenharia
**D1 — Como o modo HTTP obtém sessão de campanha?**
- **Opções:**
- **A) Login REST do Payload** — `POST /api/campaignUser/login` (`{ email, password }` ou `{ username, password }`, aceito pelo `loginWithUsername.allowEmailLogin` da collection) → resposta `{ token, user }` → `request.newContext({ storageState: { cookies: [{ name: 'campaign-token', value: token, domain, path: '/campanha', expires: -1, httpOnly: true, secure: scheme === 'https:', sameSite: 'Lax' }], origins: [] } })`. O token é o JWT canônico com `_sid`, verificado por `payload.auth()` em `getCampaignUser` — o mesmo fluxo de `loginCampaign`, sem reimplementar nada.
- **B) Login de browser real num setup spec** (recomendação da intenção) — um login por run numa spec `setup` → `storageState` em arquivo → `request.newContext({ storageState: arquivo })`. Lança browser (pelo menos 1 navegador no run), depende de artefato de estado gravado em disco e de o projeto `setup` executar antes.
- **C) Assinar JWT no teste** com `PAYLOAD_SECRET` + inserir sessão via Local API — duplica a lógica de auth do app no teste (anti-goal explícito da intenção).
- **Recomendação: A** — é a única 100% sem browser (o título da Issue é literalmente "sem browser"), usa o endpoint real do app sobre HTTP real, token canônico com sessão real, e o cookie com `path: /campanha` replica o contrato de `setCampaignAuthCookie` (`src/utilities/campaignAuth.ts:163-169`). O TTL não é re-assinado para 8 h como o login do app faz (o login REST devolve o token de 14 dias configurado em `tokenExpiration`) — irrelevante para uma sessão de teste de minutos; o servidor valida o JWT, não o atributo do cookie.
- **Rejeitadas:** B porque lança browser no run (o que este item quer eliminar) e cria dependência de artefato/ordem entre projetos; C porque viola "não duplicar lógica de auth no teste" e mexe com segredo de produção.
**D2 — Nomenclatura e registro no Playwright**
- **Opções:**
- **A) Mesmo projeto `campaign`**, arquivo `campaign<Family>Http.e2e.spec.ts` (ex.: `campaignTerritoriesHttp.e2e.spec.ts`). Casa com o `testMatch` atual `/campaign.*\.e2e\.spec\.ts/` e com o `E2E_SPEC_RE = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/` do `test-affected-core.mjs`. A dependência `setup` (prewarm) já está declarada no projeto.
- **B) Projeto Playwright próprio** `campaign-http` com `testMatch: /\.http\.e2e\.spec\.ts$/` (a letra `*.http.e2e.spec.ts` da intenção) — bloco de config novo, `dependencies: ['setup']` repetido, e dois projetos para a mesma família.
- **C) Manter o nome do arquivo da família** (`campaignTerritories.e2e.spec.ts`) e marcar o modo só em comentário — o modo fica invisível para config/scripts/humano.
- **Recomendação: A** — mesma recomendação da intenção ("família própria no projeto existente, menos configuração, mesmo servidor"), com o ajuste de nomenclatura obrigatório descoberto na análise (a letra da intenção não roda no testMatch atual). O sufixo `Http` é o marcador de modo.
- **Rejeitadas:** B porque adiciona config e estado de projeto sem ganho funcional (o job CI já roda todos os projetos com `pnpm test:e2e`); C porque esconde o modo.
**D3 — Base de fixtures do modo HTTP**
- `tests/e2e/fixtures/campaignHttpTest.ts`:
- `test` estende do **`test` cru de `@playwright/test`** — nunca de `e2eTest.ts`: o guard `e2eFailureGuard` é `auto: true` e puxaria os fixtures `page`/`context`, lançando browser em toda spec (aceite da intenção: o guard de falhas client permanece exclusivo das specs de browser).
- Reusa o fixture `campaign` de `campaignE2EFixtures.ts` (ownership/cleanup/DB in-process — mesmo padrão, **nenhum caminho de seed paralelo**). Para isso, o fixture hoje inline vira `export const campaignFixture` (mudança mínima, comportamento idêntico no `test` atual).
- Novo fixture `campaignRequest(user, password) → Promise<APIRequestContext>`: login REST (D1) + contexto por teste com o cookie. Espelha o formato de `campaign.login` (browser): recebe identificador + senha, devolve um contexto autenticado.
- `CAMPAIGN_TOKEN_COOKIE` hardcoded como `'campaign-token'` com comentário apontando `src/utilities/campaignAuth.ts:12` (não é importável: módulo `server-only`; precedente: `tests/unit/campaignLoginAction.unit.spec.ts:27`).
- `secure` derivado do scheme do `baseURL` (`https:` → true), nunca do `NODE_ENV` do processo de teste — todos os ambientes de teste usam `http://localhost:<porta>` (CI: `pnpm start` com `PLAYWRIGHT_BASE_URL` local), e o request context do Playwright trata localhost como origem confiável para cookies Secure.
- **Depth check:** o helper de login é o único lugar que conhece a forma do corpo REST + cookie; o fixture `campaign` já é o módulo profundo de ownership — não criar twin.
**D4 — Primeira família: `campaignTerritories` (4 specs, migração com equivalência)**
O motivo da escolha: `loadTerritoryOverview` + `sortTerritoryRows/filterTerritoryRows` rodam **no servidor** (`page.tsx:41-45`), a tabela é RSC com `CampaignTable` filtrando colunas ocultas **antes do render** (`CampaignTable.tsx:168` `resolveVisibleColumns`), e o estado de sort/filtro é URL. As 4 specs atuais são navegação + conteúdo renderizado + redirect — nada de hidratação/interação client. Equivalências:
| Spec browser (atual) | Equivalente HTTP |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| staff sort/filtra/abre fila de municípios (cliques em links) | GET `/campanha/territorios` → 200 + HTML com link de sidebar `Territórios` (href `/campanha/territorios`), link `Irecê (N)`; GET `?sort=votes2022` → HTML com caption `Ordenado por 2022 (maior primeiro)` (`formatTerritoryListSortSummary`, `territoryListUrl.ts:74-82`); GET `?region=Irecê` → HTML com `1 território encontrado` (footer `CampaignListFooter`); GET `/campanha/municipios?region=Irecê` → 200 + conteúdo de municípios do recorte |
| âncoras hash dos territórios-pai | GET `/campanha/territorios` → HTML contém `id="ti-irece"` e `id="ti-velho-chico"` (`territoryAnchorId`) |
| colunas de rede em painel largo (2200px) | GET `/campanha/territorios` → HTML contém os headers sortáveis `2022`/`Captura`/`2026`/`Classe`/`Assessoria` e os headers de rede `Assessor`/`Liderança`/`Dobradinha` (sempre renderizados, rungs CSS por container query — `TerritoryListColumns.tsx:247-253`); `Cobertura` **ausente** do HTML (oculta por default via `columnVisibility` → não renderizada). Perda documentada: a **visibilidade CSS por viewport** é concern de browser (anti-goal "viewports" da intenção); o contrato de servidor (o que é renderizado por estado) fica 100% pinado |
| leader não abre a página | GET `/campanha/territorios` com sessão leader (login **por username/telefone**, como no spec browser) → `maxRedirects: 0` aceita `200 | 307 | 308`e assere o **destino** do redirect, nunca o transporte:`200`→ meta`http-equiv="refresh" content="1;url=/campanha/contatos"`(Next`next-route-redirect`—`redirect()`pós-stream responde 200 + meta, medido em dev e prod);`3xx`→ header`location`terminando em`/campanha/contatos`. GET `/campanha/contatos`(home do leader) → HTML **sem** link`Territórios` |
Famílias rejeitadas para esta primeira leva (medição decide a próxima): `campaignLeaderships`/`campaignConcepts`/`campaignAgendaFeed`/`campaignActivity`/`campaignHomeActions` (interação client é o ponto — popover/autosave/dialog/forms), `campaignMunicipalities` (a mais pesada, mas maior superfície client), `campaignNearestMunicipality`/`campaignZoneMap` (geolocalização/mapa Leaflet).
## Componentes / mudanças
- **`campaignFixture` exportado** (`tests/e2e/fixtures/campaignE2EFixtures.ts`): o fixture inline atual vira `export const campaignFixture`; o `test` existente continua `base.extend({ campaign: campaignFixture })` — zero mudança de comportamento.
- **`campaignHttpTest.ts`** (`tests/e2e/fixtures/`): base crua + fixture `campaignRequest` (D1/D3). Sem guard de falhas client (documentado no header do arquivo).
- **`campaignTerritoriesHttp.e2e.spec.ts`** (novo, 4 specs de D4) e **remoção de `campaignTerritories.e2e.spec.ts`** (browser) — nada fica duplicado.
- **`scripts/lib/e2e-affected-manifest.mjs`**: entrada `specs: ['campaignTerritories']` → `['campaignTerritoriesHttp']` (o pin unit `e2eAffectedManifest.unit.spec.ts` valida que os nomes existem como arquivos).
- **Migration:** nenhuma. **Access/Consent:** nenhum toque. **UI:** nenhuma.
- **CHANGELOG-AGENTS.md:** entrada com a medição (D5).
### Dados → forma
- Forma: wall time por spec (relatório list do Playwright) nas duas versões da família, mesma máquina/worktree, mesmo servidor dev, após prewarm. Registro: `campaignTerritories — browser X s vs HTTP Y s (−Z%)` + nota do ambiente (dev server, porta do worktree). Nenhuma superfície de dados de produto (intenção: métrica de processo).
## Fases verificáveis
1. **Tracer/infra** (quota: 0,35 dia) — export `campaignFixture`; `campaignHttpTest.ts` com `campaignRequest`; spec smoke de prova (`campaignHttpSmoke` temporário, removido no fim da fase ou fundido): login REST → GET `/campanha` autenticado 200 + nome do usuário no HTML; redirect leader 3xx. Verificação: spec roda **sem lançar browser** (rodar com `--project=campaign` e conferir que nenhum browser abre — o `request` fixture não lança).
2. **Migração** (quota: 0,4 dia) — `campaignTerritoriesHttp.e2e.spec.ts` com as 4 specs equivalentes (asserções ajustadas ao markup real verificado no passo 1); remover o spec browser; atualizar o manifesto e2e-affected; `pnpm test:e2e` das 4 specs verde (dev) + `E2E_PROD=1` na entrega.
3. **Medição + fechamento** (quota: 0,3 dia) — medir browser (baseline: as 4 specs ainda em git, ou registro de antes da migração) vs HTTP; entrada no CHANGELOG-AGENTS; gates `pnpm gate:fast`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm format:check`, `pnpm build` (DB local), scan Aikido dos arquivos tocados; `pnpm push` → PR `--base main` `Closes #600` → auto-merge → `gh pr checks --watch --required`.
## Rabbit holes / Não escopo (engenharia)
- Migrar mais famílias nesta entrega — a medição desta família é o input da decisão da próxima leva (item próprio).
- storageState em arquivo / login por projeto — contexto por teste, criado sob demanda.
- "Aproveitar e asserir as rotas JSON de mutação" (`campaignJsonMutationRoute`) — superfície legítima do paradigma, mas é família **nova**, não migração; fora deste corte.
- Parser de HTML / framework de asserção — `response.text()` + `toContain`/regex é suficiente (Playwright já dá o `expect`).
- Chamar server actions por HTTP (protocolo RSC criptografado) — anti-goal da intenção.
- Mexer no `playwright.config.ts` (projects/timeouts) — não precisa.
## Adiado com gatilho (triage capture-review-debts 2026-08-10)
- **Constantes de cookie `campaign-token`/`/campanha` compartilhadas** (módulo em `src/lib/`, precedente `campaignSessionTtl.ts`): hoje hardcoded no fixture com comentário apontando `campaignAuth.ts` (módulo `server-only`). Risco real baixo — um rename quebra os specs HTTP ruidosamente (a suíte é o guard). **Gatilho:** mudança do contrato de cookie OU 3º consumidor.
- **`rendered()` (strip de `<!-- -->`) no base `campaignHttpTest.ts`**: hoje local na spec; será idêntico na próxima família. **Gatilho:** 2ª família HTTP migrar (mover junto, precedente `campaignPageChrome` no `campaignE2EFixtures`).
- **Visibilidade CSS das colunas de rede de Territórios** (rungs `@min-[Nrem]`): a família migrou inteira e o spec browser de 2200px saiu; o HTML pina presença + classes de rung, mas uma regressão de CSS (container queries) passaria. **Candidato a Issue pequena própria** (ver tabela de triage no gate) ou **gatilho:** regressão de CSS de colunas medida / próxima família com responsividade migrar.
## Riscos e mitigação
- **REST login do Payload inexercitado no app** (o login do app usa Local API): a rota `/api/campaignUser/login` é default do Payload (sem `api: false` na collection), mas o smoke da fase 1 prova o contrato real antes da migração. Se algo bloquear (ex.: access control customizado), cair para B (storageState via browser) com reabertura do gate.
- **HTML grande e substrings ambíguas** ("Territórios" aparece em sidebar/breadcrumb): asserções por par específico (href + texto, caption exato, `1 território encontrado` exato), verificadas contra o markup real no passo 1.
- **Redirect dev vs prod**: `redirect()` lançado **pós-stream** (gate `noLeader` da página, depois do layout) responde **200 com meta `next-route-redirect`** em dev e prod (medido); quando vence o stream vira 307 (dev) / 308 (prod). A asserção aceita os três e pina o **destino** (`meta url` ou `location`), nunca o transporte.
- **Renomear spec quebra pins/contagens**: manifesto e2e-affected (unit-pinned) atualizado; contagem de specs não muda (4 → 4); TESTING.md não conta por nome.
- **Medição contaminada por cold compile**: prewarm (`setup` project) antes de medir as duas versões; medição em execução única no mesmo worktree.
- **Cookie Secure no request context**: `secure` derivado do scheme; em CI/`E2E_PROD` o baseURL é `http://localhost` (origem confiável) — validado no smoke.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (modo sem browser no job existente; família migrada com asserções equivalentes; medição no changelog; RBAC leader no conteúdo/redirect; guard client exclusivo do browser)
- [ ] Invariantes AGENTS/engineering-standards (sem migration, sem banco prod, identificadores em inglês, `assertTestDatabase` no fixture reusado, zero caminho de seed paralelo)
- [ ] Testes previstos: as 4 specs HTTP migradas são os testes; smoke de login na fase 1; pins unit existentes mantidos (manifesto e2e-affected atualizado, não enfraquecido)

View file

@ -0,0 +1,42 @@
# Restaurar pin de visibilidade CSS das colunas de rede em Territórios
Status: rascunho
Atualizado em: 2026-08-10
Issue: #606 (depende de #600)
Priority: P3
Model: composer-2.5
Impeccable: A — N/A (infra de testes; sem UI nova)
Canvas UI: N/A — sem UI
Appetite: ~0,25 dia eng; um outcome verificável
Responsável: —
## Intenção
A migração OPS35 de `campaignTerritories` para o modo HTTP sem browser removeu o spec de browser de 2200px que asserava a **visibilidade CSS real** das colunas de rede (Assessor / Liderança / Dobradinha) na tabela de Territórios — as rungs `@min-[Nrem]/territory-list:table-cell` dependem de container queries, e o pin HTTP atual só cobre **presença no HTML + classes de rung**, não o efeito visual. O aceite da intenção OPS35 ("nada é removido sem equivalente em outro lugar"; "viewports continuam em browser") deixa esse buraco: uma regressão de CSS (Tailwind/container queries/styles.css) passaria verde.
## Objetivo e aceite
- Um spec de browser **mínimo** (só este comportamento) pina que, a 2200px, os columnheaders de rede de `/campanha/territorios` estão **visíveis** e o restante do contrato (sortables) permanece visível — e que continua verde no job e2e existente.
- Nada além disso: não re-migra a família, não duplica asserções do `campaignTerritoriesHttp` (presença/rungs continuam lá), não mexe em CSS.
## Direção no codebase (hipótese)
- Spec novo mínimo `tests/e2e/campaignTerritoriesColumns.e2e.spec.ts` (browser, projeto `campaign` — mesmo padrão das famílias browser atuais, login via `campaign.login`).
- Assert com `getByRole('columnheader', ...)` visível em viewport 2200px (o corpo exato do spec antigo `campaignTerritories.e2e.spec.ts` — `git show` do spec deletado, teste 3).
- Manifesto `e2e-affected`: rota `territorios` → specs `['campaignTerritoriesHttp', 'campaignTerritoriesColumns']`.
## Dependências
- Nenhuma dura. Nasce `blocked` (OPS17) até o plano estar em `main`; destrava quando #600 flipar `done`.
## Fora de escopo
- Re-migrar famílias; tocar o spec HTTP; qualquer mudança de CSS/UI.
## Rabbit holes de produto
- **"Aproveitar e pinar as rungs de todas as listas"** — escopo explode; só Territórios (a família que perdeu o pin).
## Questões em aberto (produto)
- Nenhuma — comportamento já foi aceito pelo spec deletado (B175); isto é restaurar cobertura, não decidir produto.

View file

@ -68,7 +68,7 @@ export const E2E_AFFECTED_MANIFEST = [
'src/components/campaign/tour',
'src/utilities/territory',
],
specs: ['campaignTerritories'],
specs: ['campaignTerritoriesHttp'],
},
{
prefixes: [`${CAMPAIGN_APP}/conceitos`],

View file

@ -203,7 +203,10 @@ export function selectE2eSpecs(files, manifest) {
}
const specs = new Set()
const unmapped = []
for (const { path } of files) {
for (const { path, status } of files) {
// A deleted spec file cannot run — selecting it would make CI fail with
// "No tests found" (the stale-manifest variant of a spec rename).
if (status === 'D' && E2E_SPEC_PATTERN.test(path)) continue
if (E2E_SPEC_PATTERN.test(path)) {
specs.add(path.slice('tests/e2e/'.length, -'.e2e.spec.ts'.length))
continue

View file

@ -1,110 +0,0 @@
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
test.describe('Territórios de Identidade', () => {
test('staff sorts, filters and opens the municipality queue for a territory', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Territórios'),
})
const password = coordinator.password
const email = coordinator.email!
await campaign.login(page, email, password)
await page.getByRole('link', { name: 'Territórios', exact: true }).click()
await expect(page).toHaveURL(/\/campanha\/territorios$/)
await expect(campaignPageChrome(page, 'Territórios de Identidade')).toBeVisible()
// B175 — the count rides the name ("Irecê (N)") and the header is short ("2022").
await expect(page.getByRole('link', { name: /Irecê \(\d+\)/ }).first()).toBeVisible()
await expect(page.getByRole('columnheader', { name: '2022' })).toBeVisible()
await page.getByRole('link', { name: /Ordenar por 2022/ }).click()
await expect(page).toHaveURL(/\/campanha\/territorios\?sort=votes2022/)
await page.getByRole('button', { name: 'Filtrar por Território' }).click()
await page.locator('a[href*="/campanha/territorios?region=Irec"]').click()
await expect(page).toHaveURL(/region=Irec%C3%AA/)
await expect(page.getByText('1 território encontrado')).toBeVisible()
await page
.getByRole('link', { name: /Irecê \(\d+\)/ })
.first()
.click()
await expect(page).toHaveURL(/\/campanha\/municipios\?region=Irec%C3%AA/)
})
test('parent territory rows expose hash anchor ids for deep links', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Âncoras'),
})
const password = coordinator.password
const email = coordinator.email!
await campaign.login(page, email, password)
await page.goto('/campanha/territorios')
// `.first()`: transient RSC-pending duplication copies the rows (observed
// strict-mode flake on loaded machines; CI stays green).
await expect(page.locator('#ti-irece').first()).toBeVisible()
await expect(page.locator('#ti-velho-chico').first()).toBeVisible()
})
test('a wide panel surfaces the read-only network columns; Cobertura stays hidden by default', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Rede Larga'),
})
const password = coordinator.password
const email = coordinator.email!
await page.setViewportSize({ width: 2200, height: 1000 })
await campaign.login(page, email, password)
await page.goto('/campanha/territorios')
// B175 — the responsive rungs are all visible at a wide panel (P0 + Captura
// + network + Classe + Assessoria). Cobertura is P3 AND hidden in the picker.
// Sortable headers are announced "Ordenar por <label> (…)" — match by prefix;
// the read-only network headers are plain, so match them exactly (never by
// substring, since "Assessor" is a prefix of "Assessoria").
for (const header of ['2022', 'Captura', '2026', 'Classe', 'Assessoria']) {
await expect(
page.getByRole('columnheader', { name: new RegExp(`^Ordenar por ${header}`) }),
`header ${header}`,
).toBeVisible()
}
for (const header of ['Assessor', 'Liderança', 'Dobradinha']) {
await expect(
page.getByRole('columnheader', { name: header, exact: true }),
`header ${header}`,
).toBeVisible()
}
await expect(page.getByRole('columnheader', { name: 'Cobertura' })).toHaveCount(0)
})
test('leader cannot open the territories page', async ({ campaign, page }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança Territórios'),
username: phone,
})
const password = leader.password
await campaign.login(page, phone, password)
await expect(page.getByRole('link', { name: 'Territórios', exact: true })).toHaveCount(0)
await page.goto('/campanha/territorios')
await expect(page).toHaveURL(/\/campanha\/contatos/)
})
})

View file

@ -0,0 +1,158 @@
import { expect, test } from './fixtures/campaignHttpTest.js'
/**
* OPS35 — `campaignTerritories` migrated to the browserless HTTP mode: same
* server, same database, real HTTP, no browser. The family's assertions were
* server-rendered content + URL contracts + a route-level redirect, so the
* equivalence is direct: what the browser spec read from the DOM is read from
* the rendered HTML; what it navigated by click is requested by URL.
*
* Two render realities shaped the assertions (verified against the real
* markup): React's streaming inserts `<!-- -->` text-node separators, so text
* assertions run over `rendered()` (comments stripped — they are transport,
* not content); and the Cobertura column label lives in the RSC flight
* payload (the column picker menu is a client component whose props serialize
* even when closed), so "hidden by default" is pinned by the absence of its
* sortable control, which `resolveVisibleColumns` never renders
* (CampaignTable.tsx:168). CSS rung visibility at a given viewport is a
* browser concern (OPS35 keeps viewports in the browser families) — the
* server contract asserted here is which columns are rendered for a given
* state.
*/
const rendered = (html: string) => html.replaceAll('<!-- -->', '')
test.describe('Territórios de Identidade (HTTP)', () => {
test('staff sorts, filters and opens the municipality queue for a territory', async ({
campaign,
campaignRequest,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Territórios'),
})
const request = await campaignRequest(coordinator, coordinator.password)
// The sidebar way in — the same link the browser spec clicks.
const list = await request.get('/campanha/territorios')
expect(list.status()).toBe(200)
const listHTML = rendered(await list.text())
expect(listHTML).toContain('Territórios de Identidade')
expect(listHTML).toContain('href="/campanha/territorios"')
// B175 — the count rides the name ("Irecê (N)"): the row link leads with
// the territory name and carries the municipality count.
expect(listHTML).toMatch(/Irecê <span[^>]*>\(\d+\)/)
// The controls the browser spec exercised by click are anchors: pin their
// hrefs so a broken affordance fails here instead of passing silently.
expect(listHTML).toContain('href="/campanha/municipios?region=Irec%C3%AA"')
expect(listHTML).toContain('href="/campanha/territorios?sort=votes2022')
// Sorting is a URL contract: the server renders the summary in the caption.
const sorted = await request.get('/campanha/territorios?sort=votes2022')
expect(sorted.status()).toBe(200)
expect(rendered(await sorted.text())).toContain('Ordenado por 2022 (maior primeiro)')
// Filtering is the same contract: the footer reports the recorte.
const filtered = await request.get('/campanha/territorios?region=Irecê')
expect(filtered.status()).toBe(200)
expect(rendered(await filtered.text())).toContain('1 território encontrado')
// The row opens the municipality queue for the territory.
const queue = await request.get('/campanha/municipios?region=Irecê')
expect(queue.status()).toBe(200)
expect(rendered(await queue.text())).toContain('Irecê')
})
test('parent territory rows expose hash anchor ids for deep links', async ({
campaign,
campaignRequest,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Âncoras'),
})
const request = await campaignRequest(coordinator, coordinator.password)
const response = await request.get('/campanha/territorios')
expect(response.status()).toBe(200)
const html = await response.text()
expect(html).toContain('id="ti-irece"')
expect(html).toContain('id="ti-velho-chico"')
})
test('the read-only network columns render in the server HTML; Cobertura stays hidden by default', async ({
campaign,
campaignRequest,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Rede Larga'),
})
const request = await campaignRequest(coordinator, coordinator.password)
const response = await request.get('/campanha/territorios')
expect(response.status()).toBe(200)
const html = await response.text()
// B175 — the sortable rungs (P0 + Captura + network + Classe + Assessoria)
// announce "Ordenar por <label> (…)" via aria-label. No `(` anchor: the
// active sort renders "Ordenar por X, inverter para …", the inactive one
// "Ordenar por X (…)" — both must match (the assertion is about the
// column existing, not about which sort is active).
for (const header of ['2022', 'Captura', '2026', 'Classe', 'Assessoria']) {
expect(html, `sortable header ${header}`).toContain(`aria-label="Ordenar por ${header}`)
}
// The read-only network headers are plain text — match exactly (never by
// substring: "Assessor" is a prefix of "Assessores"/"Assessoria"). Their
// panel-width rung classes are server-rendered too, so the "wide panel
// shows them" contract keeps a server-side pin (CSS visibility at a
// viewport stays a browser concern).
const networkRungs: Array<[string, string]> = [
['Assessor', 'hidden @min-[60rem]/territory-list:table-cell'],
['Liderança', 'hidden @min-[66rem]/territory-list:table-cell'],
['Dobradinha', 'hidden @min-[72rem]/territory-list:table-cell'],
]
for (const [header, rung] of networkRungs) {
expect(html, `network header ${header}`).toContain(`${header}<`)
expect(html, `network rung ${header}`).toContain(rung)
}
// Cobertura is P3 AND hidden in the picker: the hidden column is never
// rendered (its sortable control is absent), even though its label exists
// in the RSC flight payload as the picker menu item.
expect(html).not.toContain('Ordenar por Cobertura')
})
test('leader cannot open the territories page', async ({ campaign, campaignRequest }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança Territórios'),
username: phone,
})
// Log in by username (phone), the way the browser spec did — the REST
// login accepts email or username and both branches are exercised.
const request = await campaignRequest({ username: phone }, leader.password)
// The `noLeader` gate throws `redirect()` deep in the page, after the
// layout has started streaming — so the document usually answers 200
// carrying Next's route-redirect meta tag instead of a 3xx. When the
// redirect beats the stream it is a real 3xx (dev 307 / prod 308). Both
// are the server contract the browser follows — assert the redirect
// target, never pin the transport.
const direct = await request.get('/campanha/territorios', { maxRedirects: 0 })
expect([200, 307, 308]).toContain(direct.status())
if (direct.status() === 200) {
expect(await direct.text()).toContain(
'http-equiv="refresh" content="1;url=/campanha/contatos"',
)
} else {
expect(direct.headers()['location']).toMatch(/\/campanha\/contatos$/)
}
// The leader home renders no way in.
const home = await request.get('/campanha/contatos')
expect(home.status()).toBe(200)
expect(await home.text()).not.toContain('href="/campanha/territorios"')
})
})

View file

@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { sql } from '@payloadcms/db-postgres'
import type { Page } from '@playwright/test'
import type { Fixtures, Page } from '@playwright/test'
import { getPayload, type CollectionSlug, type Payload, type PayloadRequest } from 'payload'
import { municipalityCatalog } from '../../../src/lib/municipalityCatalog.js'
@ -531,57 +531,67 @@ type CampaignE2ETestFixtures = {
campaign: CampaignE2EFixture
}
export const test = base.extend<CampaignE2ETestFixtures>({
campaign: async ({}, runFixture) => {
assertTestDatabase(process.env.DATABASE_URL)
const payload = await getPayload({ config })
const fixtures = new CampaignE2EOwnership(payload)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? defaultBaseURL
/**
* The shared campaign ownership fixture (in-process Payload, auto-owned rows,
* municipality claims, transactional cleanup). Exported so the browser suite
* (`campaignE2EFixtures.ts`) and the browserless HTTP suite
* (`campaignHttpTest.ts`) both extend from the same definition — never twin it.
*/
type CampaignE2EFixtureValue = Fixtures<CampaignE2ETestFixtures>['campaign']
let testFailure: unknown
export const campaignFixture: CampaignE2EFixtureValue = async ({}, runFixture) => {
assertTestDatabase(process.env.DATABASE_URL)
const payload = await getPayload({ config })
const fixtures = new CampaignE2EOwnership(payload)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? defaultBaseURL
let testFailure: unknown
try {
await runFixture({
baseURL,
fixtures,
login: async (page, identifier, password) => {
await page.context().clearCookies()
await page.goto(`${baseURL}/campanha/login`)
await page.getByLabel('E-mail ou celular').fill(identifier)
await page.getByLabel('Senha').fill(password)
// `exact`: the login screen also offers "Entrar com digital ou Face
// ID" (B40) whenever the device has a platform authenticator — which
// any spec adding a virtual one does.
await page.getByRole('button', { name: 'Entrar', exact: true }).click()
await page.waitForURL(`${baseURL}/campanha`)
},
payload: fixtures.payload,
transaction: (operation) =>
withPayloadTransaction(payload, ({ req }) => operation(fixtures.payload, req)),
withInviteConsent,
})
} catch (error) {
testFailure = error
throw error
} finally {
let cleanupFailure: unknown
try {
await runFixture({
baseURL,
fixtures,
login: async (page, identifier, password) => {
await page.context().clearCookies()
await page.goto(`${baseURL}/campanha/login`)
await page.getByLabel('E-mail ou celular').fill(identifier)
await page.getByLabel('Senha').fill(password)
// `exact`: the login screen also offers "Entrar com digital ou Face
// ID" (B40) whenever the device has a platform authenticator — which
// any spec adding a virtual one does.
await page.getByRole('button', { name: 'Entrar', exact: true }).click()
await page.waitForURL(`${baseURL}/campanha`)
},
payload: fixtures.payload,
transaction: (operation) =>
withPayloadTransaction(payload, ({ req }) => operation(fixtures.payload, req)),
withInviteConsent,
})
assertTestDatabase(process.env.DATABASE_URL)
await fixtures.cleanup()
await fixtures.expectNoOwnedRows()
} catch (error) {
testFailure = error
throw error
} finally {
let cleanupFailure: unknown
try {
assertTestDatabase(process.env.DATABASE_URL)
await fixtures.cleanup()
await fixtures.expectNoOwnedRows()
} catch (error) {
cleanupFailure = error
}
if (cleanupFailure !== undefined) {
if (testFailure !== undefined) {
throw new AggregateError(
[testFailure, cleanupFailure],
'Campaign E2E test and ownership cleanup both failed.',
)
}
throw cleanupFailure
}
cleanupFailure = error
}
},
if (cleanupFailure !== undefined) {
if (testFailure !== undefined) {
throw new AggregateError(
[testFailure, cleanupFailure],
'Campaign E2E test and ownership cleanup both failed.',
)
}
throw cleanupFailure
}
}
}
export const test = base.extend<CampaignE2ETestFixtures>({
campaign: campaignFixture,
})
export const campaignPageChrome = (page: Page, title: string | RegExp) =>

View file

@ -0,0 +1,112 @@
import type { APIRequestContext } from '@playwright/test'
import { test as base, expect, request as playwrightRequest } from '@playwright/test'
import { campaignFixture, type CampaignE2EFixture } from './campaignE2EFixtures.js'
/**
* Browserless full-stack e2e base (OPS35): same job, same server, same
* database, real HTTP — no browser ever launches.
*
* Deliberately extends the RAW `@playwright/test` test, not `e2eTest.ts`:
* `e2eFailureGuard` is `auto: true` and would pull the `page`/`context`
* fixtures, launching a browser in every spec. The client-failure guard stays
* exclusive to browser specs — the HTTP mode does not pretend to observe what
* it cannot see (intention OPS35).
*
* The `campaign` fixture is the shared one from the browser suite (ownership,
* in-process Payload, cleanup) — fixtures are created the same way, no
* parallel seeding path.
*/
// Same stable contract `setCampaignAuthCookie` writes
// (src/utilities/campaignAuth.ts:12) — the module is `server-only` and cannot
// be imported from the test process (precedent: tests/unit/campaignLoginAction.unit.spec.ts:27).
const CAMPAIGN_TOKEN_COOKIE = 'campaign-token'
const CAMPAIGN_COOKIE_PATH = '/campanha'
type CampaignRequestLogin = (
user: { email?: string | null; username?: string | null },
password: string,
) => Promise<APIRequestContext>
type CampaignHttpTestFixtures = {
campaign: CampaignE2EFixture
campaignRequest: CampaignRequestLogin
}
/**
* Authenticates through the app's real login endpoint (Payload REST
* `loginOperation` — the same operation `loginCampaign` calls) and returns a
* request context carrying the canonical `campaign-token` cookie scoped to
* `/campanha`, mirroring `setCampaignAuthCookie`'s options. No browser, no
* duplicated auth logic, no re-signed TTL: the REST login returns the
* configured 14-day token, which is more than enough for a test session.
*/
const loginCampaignRequest = async (
baseURL: string,
user: { email?: string | null; username?: string | null },
password: string,
): Promise<{ token: string; user: { id: number | string } }> => {
const loginContext = await playwrightRequest.newContext({ baseURL })
try {
const body =
user.email !== undefined && user.email !== null
? { email: user.email, password }
: { username: user.username ?? '', password }
const login = await loginContext.post('/api/campaignUser/login', { data: body })
if (!login.ok()) {
throw new Error(
`Campaign REST login failed: ${login.status()} ${(await login.text()).slice(0, 300)}`,
)
}
return (await login.json()) as { token: string; user: { id: number | string } }
} finally {
await loginContext.dispose()
}
}
const campaignTokenStorageState = (baseURL: string, token: string) => {
const url = new URL(baseURL)
return {
cookies: [
{
name: CAMPAIGN_TOKEN_COOKIE,
value: token,
domain: url.hostname,
path: CAMPAIGN_COOKIE_PATH,
expires: -1,
httpOnly: true,
secure: url.protocol === 'https:',
sameSite: 'Lax' as const,
},
],
origins: [],
}
}
export const test = base.extend<CampaignHttpTestFixtures>({
campaign: campaignFixture,
// Array form (same as `e2eTest.ts`'s guard): the shorthand property names
// the function `campaignRequest`, which trips the react-hooks rule on the
// fixture's `use(...)` callback. Factory-created contexts are not
// auto-disposed by Playwright; the per-test closure tracks them and
// disposes them at teardown.
campaignRequest: [
async ({ campaign }, use) => {
const contexts: APIRequestContext[] = []
await use(async (user, password) => {
const { token } = await loginCampaignRequest(campaign.baseURL, user, password)
const context = await playwrightRequest.newContext({
baseURL: campaign.baseURL,
storageState: campaignTokenStorageState(campaign.baseURL, token),
})
contexts.push(context)
return context
})
await Promise.all(contexts.map((context) => context.dispose()))
},
{},
],
})
export { expect }

View file

@ -11,6 +11,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/municipios',
'/campanha/territorios',
'/campanha/municipios/e2e-prewarm',
'/campanha/contatos',
'/campanha/perfil',
'/campanha/demandas',
'/campanha/liderancas',

View file

@ -105,6 +105,22 @@ describe('selectE2eSpecs (OPS5)', () => {
expect(result.specs).toContain('campaignAuth')
})
it('skips deleted e2e specs (they cannot run)', () => {
const result = selectE2eSpecs(
[{ path: 'tests/e2e/campaignTerritories.e2e.spec.ts', status: 'D' }],
manifest,
)
expect(result).toMatchObject({ mode: 'none', specs: [], unmapped: [] })
})
it('still maps deleted src files through the manifest', () => {
const result = selectE2eSpecs(
[{ path: 'src/components/campaign/municipality/MunicipalityTable.tsx', status: 'D' }],
manifest,
)
expect(result).toMatchObject({ mode: 'selected', specs: ['campaignMunicipalities'] })
})
it('runs the full suite on high-risk paths', () => {
expect(selectE2eSpecs([changed('tests/e2e/fixtures/auth.ts')], manifest).mode).toBe('full')
expect(selectE2eSpecs([changed('src/migrations/x.ts')], manifest).mode).toBe('full')