mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
121 lines
19 KiB
Markdown
121 lines
19 KiB
Markdown
# 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)
|