test(OPS35): modo e2e full-stack sem browser (paradigma HTTP) + migração da família Territórios (Closes #600)
This commit is contained in:
parent
b4278f1632
commit
1f31d1b628
11 changed files with 514 additions and 397 deletions
File diff suppressed because one or more lines are too long
121
docs/plans/e2e-full-stack-sem-browser-impl.md
Normal file
121
docs/plans/e2e-full-stack-sem-browser-impl.md
Normal 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)
|
||||
42
docs/plans/restaurar-pin-visibilidade-colunas-territorios.md
Normal file
42
docs/plans/restaurar-pin-visibilidade-colunas-territorios.md
Normal 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.
|
||||
|
|
@ -68,7 +68,7 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/components/campaign/tour',
|
||||
'src/utilities/territory',
|
||||
],
|
||||
specs: ['campaignTerritories'],
|
||||
specs: ['campaignTerritoriesHttp'],
|
||||
},
|
||||
{
|
||||
prefixes: [`${CAMPAIGN_APP}/conceitos`],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
158
tests/e2e/campaignTerritoriesHttp.e2e.spec.ts
Normal file
158
tests/e2e/campaignTerritoriesHttp.e2e.spec.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
112
tests/e2e/fixtures/campaignHttpTest.ts
Normal file
112
tests/e2e/fixtures/campaignHttpTest.ts
Normal 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 }
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue