Merge pull request #51 from fsolla/agent/ops-ci-workflow

chore(ci): parallel PR jobs, affected tests, push gate, owner-of-red-CI policy
This commit is contained in:
Francisco Solla 2026-07-30 17:51:07 -03:00 committed by GitHub
commit 621dee0440
42 changed files with 1982 additions and 455 deletions

View file

@ -11,7 +11,7 @@ Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IM
1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive.
2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment.
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (antes de cada push): `pnpm lint`, `pnpm exec tsc --noEmit`, `pnpm test:unit` (+ `pnpm format:check` e `pnpm check:cycles` antes de commitar — o pre-push hook cobre os três primeiros). Se a entrega tocou schema: também `pnpm migrate && pnpm db:seed:minimal && pnpm test:int`. **Gate completo = CI do PR** (`ci-pr.yml`: os anteriores + knip + int + build, contra o DB mínimo). O handoff humano (promote `stage→main`) exige CI stage + ci-pr green — não o build local.
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (antes de cada push): `pnpm gate:fast` (lint + typecheck + test:unit); o pre-push hook roda `pnpm gate:push` (fast + format:check + check:cycles). Fonte única: scripts `gate:*` em `package.json`; escape para WIP: `git push --no-verify`. Se a entrega tocou schema: também `pnpm migrate && pnpm db:seed:minimal && pnpm test:int`. **Gate completo = CI do PR** (`ci-pr.yml`: jobs paralelos — estático + unit/int afetados pelo diff + e2e por manifesto + build, contra o DB mínimo). O handoff humano (promote `stage→main`) exige CI stage + ci-pr green — não o build local. **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`).
4. **Run gate commands bare, never piped** (`pnpm test | tail` swallows the exit code). Never run `knip --fix` blind — verify each removal with `git grep -w <symbol>` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3).
## Type honesty

View file

@ -59,7 +59,7 @@ Ordem fixa, fases pequenas e verificáveis, respeitando o appetite do plano:
1. **Schema e server** — migrations (`pnpm migrate:create`, seguir `payload-migrations`), collections, utilities, server actions, testes de domínio. Guardrails do repo: Local API com `user` → `overrideAccess: false`; escrita multi-collection → transação com `req: { transactionID }`; pessoa → join com `Contact`; opt-in/PII → `Consent` por chave estável falhando fechado.
2. **UI via /impeccable** (classes B/C/D — a classe está no plano; se A, declare "Impeccable: N/A" e siga só engenharia): shape conforme a classe → craft → critique → (harden/optimize **só sob gatilho**) → polish. Paleta = tokens `data-theme='campaign'`; reusar `src/components/ui` e shells existentes; shape obrigatório em C **para** para confirmação do brief antes do craft.
3. **Gates de engenharia** — fast gate local: `pnpm lint`, `pnpm exec tsc --noEmit`, `pnpm test:unit`, `pnpm format:check`, `pnpm check:cycles` (+ `pnpm migrate && pnpm db:seed:minimal && pnpm test:int` se tocou schema). Scan Aikido dos arquivos editados. Comandos bare, nunca piped.
3. **Gates de engenharia** — fast gate local: `pnpm gate:fast` (lint + typecheck + test:unit) e, antes do push, `pnpm gate:push` (fast + format:check + check:cycles — o pre-push hook roda exatamente isso) (+ `pnpm migrate && pnpm db:seed:minimal && pnpm test:int` se tocou schema). Scan Aikido dos arquivos editados. Comandos bare, nunca piped.
Tracer bullet: se a Issue for grande, a primeira fatia vertical real (schema mínimo → uma action → uma superfície UI) vem cedo.
@ -73,7 +73,7 @@ Rode `/simplify` sobre o diff da sessão. Follow-ups maiores que o cleanup → `
2. Branch `agent/<id>-<slug>` (worktrees do Cursor são donos da criação; commits lógicos).
3. `gh pr create --base stage` com `Closes #<N>` no body.
4. `gh pr merge --auto --merge <PR>`.
5. **Acompanhe os checks até o merge** (`gh pr checks <PR> --watch`): falha no ci-pr → corrige na mesma branch e reempurra (o auto-merge dispara de novo quando verde).
5. **Acompanhe os checks até o merge** (`gh pr checks <PR> --watch`): falha no ci-pr → corrige na mesma branch e reempurra (o auto-merge dispara de novo quando verde). **Qualquer falha é tua** — infra do workflow, teste pré-existente ou regressão da feature; "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`; exceção de blast radius: migration/access/Consent → para e escala).
6. O flip `in-progress → done` é **determinístico no CI** (workflow `issue-done-on-stage-merge.yml` lê o `Closes #N` no merge em stage). O agente espera o merge apenas para verificar e **consolidar pontas soltas**: débitos não registrados, doc da sessão pendente (notebook do projeto + `docs/plans/<slug>.md` com Status/Atualizado em), comentário de fechamento na Issue com o que entrou. Se o agente falhar depois do merge, o status já está correto.
## Resumo final ao usuário

View file

@ -1,8 +1,14 @@
# PR gate: feature → stage (and stage → main promote PRs).
# Full gate against the MINIMAL synthetic database: service Postgres → migrate →
# db:seed:minimal → test:int → build. Build runs here (local service DB only) and
# NEVER in ci-stage.yml — `pnpm build` applies migrations to whatever DATABASE_URL
# it sees, so it is forbidden anywhere near stage/prod URLs.
# Parallel jobs with `needs` only where a real dependency exists (Postgres for
# int/build/e2e). Build runs here (local service DB only) and NEVER in
# ci-stage.yml — `pnpm build` applies migrations to whatever DATABASE_URL it
# sees, so it is forbidden anywhere near stage/prod URLs.
#
# Test selection (OPS5): unit/int run `vitest --changed origin/$GITHUB_BASE_REF`
# and e2e runs the manifest-selected specs; a diff touching a high-risk path
# (schema, lockfile, test harness, seed manifest — see
# scripts/lib/test-affected-core.mjs) falls back to the FULL suites. Push to
# stage/main always runs everything (ci-stage.yml / ci.yml).
name: CI (PR)
on:
@ -13,10 +19,115 @@ concurrency:
group: ci-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
jobs:
checks:
lint:
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm format:check
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
knip:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec knip
cycles:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm check:cycles
unit:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Compute affected test scope
id: scope
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: |
result=$(node scripts/test-affected.mjs)
echo "scope: $(echo "$result" | jq -r .reason)"
echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT"
- name: Unit tests (full suite)
if: steps.scope.outputs.mode == 'full'
run: pnpm test:unit
- name: Unit tests (changed since PR base)
if: steps.scope.outputs.mode == 'changed'
run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests
- name: Unit tests skipped
if: steps.scope.outputs.mode == 'none'
run: echo "No src/ or tests/ changes in this PR — nothing to run."
int:
runs-on: ubuntu-latest
timeout-minutes: 25
services:
postgres:
# Matches production Neon major (17) and docker-compose.yml.
@ -32,55 +143,173 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 12
env:
# Tests load .env.test themselves (database teqo_test on localhost:5432).
# These are only for the migrate/seed/build steps, which read the process env.
DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint (zero warnings)
run: pnpm lint
- name: Format check (prettier)
run: pnpm format:check
- name: Typecheck
run: pnpm typecheck
- name: Dead code (knip)
run: pnpm exec knip
- name: Circular dependencies (madge)
run: pnpm check:cycles
- name: Unit tests
run: pnpm test:unit
- name: Compute affected test scope
id: scope
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: |
result=$(node scripts/test-affected.mjs)
echo "scope: $(echo "$result" | jq -r .reason)"
echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT"
- name: Migrate test database
if: steps.scope.outputs.mode != 'none'
run: pnpm migrate
- name: Seed minimal test database
if: steps.scope.outputs.mode != 'none'
run: pnpm db:seed:minimal
- name: Integration tests
- name: Integration tests (full suite)
if: steps.scope.outputs.mode == 'full'
run: pnpm test:int
- name: Integration tests (changed since PR base)
if: steps.scope.outputs.mode == 'changed'
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
- name: Integration tests skipped
if: steps.scope.outputs.mode == 'none'
run: echo "No src/ or tests/ changes in this PR — nothing to run."
build:
runs-on: ubuntu-latest
timeout-minutes: 25
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build (against the local service database)
run: pnpm build
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Install Playwright chromium
run: pnpm exec playwright install --with-deps chromium
- name: Compute affected e2e scope
id: scope
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: |
result=$(node scripts/e2e-affected.mjs)
echo "scope: $(echo "$result" | jq -r .reason)"
echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT"
echo "specs=$(echo "$result" | jq -r '[.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT"
- name: Migrate test database
if: steps.scope.outputs.mode != 'none'
run: pnpm migrate
- name: Seed minimal test database
if: steps.scope.outputs.mode != 'none'
run: pnpm db:seed:minimal
# CI e2e runs against the production build (see playwright.config.ts
# webServer.command); the dist dir must match the webServer env.
- name: Build for e2e
if: steps.scope.outputs.mode != 'none'
run: pnpm build
env:
NEXT_DIST_DIR: .next/e2e
- name: E2E tests (full suite)
if: steps.scope.outputs.mode == 'full'
run: pnpm test:e2e
- name: E2E tests (manifest-selected specs)
if: steps.scope.outputs.mode == 'selected'
run: pnpm test:e2e -- ${{ steps.scope.outputs.specs }}
- name: E2E tests skipped
if: steps.scope.outputs.mode == 'none'
run: echo "No e2e-relevant changes in this PR — nothing to run."
# Branch protection on `stage`/`main` still requires the legacy context name
# `checks` from the pre-parallel monolithic job. This rollup satisfies that
# contract without re-serializing the suite.
checks:
name: checks
runs-on: ubuntu-latest
needs:
- lint
- format
- typecheck
- knip
- cycles
- unit
- int
- build
- e2e
- migration-lock
if: always()
steps:
- name: All parallel checks green
env:
NEEDS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
echo "$NEEDS" | jq -e '
to_entries
| all(.value.result == "success" or .value.result == "skipped")
'
migration-lock:
# At most ONE open PR may touch schema (migrations or generated types).
# Two in flight guarantees a broken rebase and a poisoned drizzle snapshot

View file

@ -1,7 +1,10 @@
# Continuous integration on GitHub Actions (GitHub is the only home).
# Post-merge gate on `main` (promote from stage already passed ci-pr on the PR).
# PRs run in ci-pr.yml (with build + migration-lock); merges to `stage` run in
# ci-stage.yml (stage Neon snapshot, never a build).
# Post-merge gate on `main` — the FULL safety net, always: the promote PR
# already passed ci-pr (possibly with affected-test selection), so this run
# re-verifies everything against the minimal synthetic database, including
# build and full e2e.
# PRs run in ci-pr.yml; merges to `stage` run in ci-stage.yml (stage Neon
# snapshot smoke — never a build there).
name: CI
on:
@ -12,10 +15,35 @@ concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
# Tests load .env.test themselves (database teqo_test on localhost:5432).
# These are only for the migrate/seed/build steps, which read the process env.
DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
jobs:
checks:
static:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm format:check
- run: pnpm typecheck
- run: pnpm exec knip
- run: pnpm check:cycles
- run: pnpm test:unit
int:
runs-on: ubuntu-latest
timeout-minutes: 25
services:
postgres:
# Matches production Neon major (17) and docker-compose.yml.
@ -31,45 +59,100 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 12
env:
# Tests load .env.test themselves (database teqo_test on localhost:5432).
# These are only for the migrate step, which reads the process env.
DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm migrate
- run: pnpm db:seed:minimal
- run: pnpm test:int
- name: Install dependencies
run: pnpm install --frozen-lockfile
build:
runs-on: ubuntu-latest
timeout-minutes: 25
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Lint (zero warnings)
run: pnpm lint
- name: Build (against the local service database)
run: pnpm build
- name: Format check (prettier)
run: pnpm format:check
e2e:
runs-on: ubuntu-latest
timeout-minutes: 40
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Install Playwright chromium
run: pnpm exec playwright install --with-deps chromium
- name: Dead code (knip)
run: pnpm exec knip
- run: pnpm migrate
- run: pnpm db:seed:minimal
- name: Circular dependencies (madge)
run: pnpm check:cycles
- name: Build for e2e
run: pnpm build
env:
NEXT_DIST_DIR: .next/e2e
- name: Unit tests
run: pnpm test:unit
- name: E2E tests (full suite)
run: pnpm test:e2e
- name: Migrate test database
run: pnpm migrate
- name: Integration tests
run: pnpm test:int
# Same legacy branch-protection context as ci-pr.yml (see comment there).
checks:
name: checks
runs-on: ubuntu-latest
needs: [static, int, build, e2e]
if: always()
steps:
- name: All parallel checks green
env:
NEEDS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
echo "$NEEDS" | jq -e '
to_entries
| all(.value.result == "success" or .value.result == "skipped")
'

View file

@ -1 +1,3 @@
pnpm lint && pnpm format:check && pnpm typecheck
# Fast gate before push (source of truth: package.json `gate:push`).
# Full gate = CI do PR (docs/AGENT-OPS.md). Escape hatch para WIP: `git push --no-verify`.
pnpm gate:push

View file

@ -21,7 +21,12 @@ stage → CI stage green → (humano) pnpm agent:promote --i-am-human → main
**Skills do fluxo (desde 2026-07-30):** `plan-issue` (ideia → plano completo + gate de confirmação + Issue via `agent:register --model`) → `work-issue` (claim → verificação de modelo → freshness audit → execução → /simplify → PR `--base stage` → acompanha CI até o merge) → `project-status` (overview/fila/mermaid/bloqueios/consolidação, read-only). `docs/roadmap.md` = **legado congelado** (stub); a fonte canônica são as GitHub Issues.
- **Agente faz sozinho:** `pnpm agent:claim` (pega a próxima Issue `ready` desbloqueada por `prio:P0..P3`, lock otimista ready→in-progress) → implementa → fast gate → `gh pr create --base stage` com `Closes #N` → acompanha `gh pr checks --watch` até o merge (falha → corrige na mesma branch). O flip `in-progress→done` é determinístico no CI (workflow `issue-done-on-stage-merge.yml`).
- **Agente faz sozinho:** `pnpm agent:claim` (pega a próxima Issue `ready` desbloqueada por `prio:P0..P3`, lock otimista ready→in-progress) → implementa → `pnpm gate:fast` → `gh pr create --base stage` com `Closes #N` → acompanha `gh pr checks --watch` até o merge (falha → corrige na mesma branch). O flip `in-progress→done` é determinístico no CI (workflow `issue-done-on-stage-merge.yml`).
### Dono do PR, dono do CI (desde 2026-07-30 — incidente PR #50)
**Done = PR mergeado em `stage` com CI verde.** Falha de CI no teu PR — infra do workflow, teste pré-existente ou regressão da feature — é tua até o merge. "Fora do escopo da feature" não é motivo para parar: corrige na mesma branch. Triagem das três classes: (a) **infra do workflow** (job mal configurado, step faltando) → fix no workflow no mesmo PR; (b) **teste pré-existente quebrado ou frágil** → repair no mesmo PR, no padrão dos specs irmãos; (c) **regressão da feature** → fix normal. Exceção de blast radius: se o fix exigir migration, access control ou Consent, para e escala com Opções — esses nunca se corrigem "de passagem". Flaky não se resolve com retry: repair, ou quarentena justificada no PR + débito registrado (`capture-review-debts`).
- **Só humano:** `pnpm agent:promote --i-am-human` (PR `stage→main` + merge com CI stage + ci-pr green; recusa se `main` divergiu de `stage` — merge main em stage antes), `pnpm db:refresh:stage` (refresh semanal do snapshot), editar envs Vercel/Neon, rodar `pnpm build` contra qualquer banco remoto.
- Se `main` divergir de `stage` (hotfix): merge/rebase de `main` em `stage` + CI stage green de novo, só depois promote.
- **Engineering-audit é solitário:** quando roda, o paralelismo pausa (nenhum outro agente no repo) e o agente do audit executa as remediações P0/P1 na mesma sessão; inclui o harvest `kind:agent-miss` → guardrails.
@ -49,14 +54,13 @@ Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:fe
## CI por alvo
| Workflow | Trigger | Banco | Passos |
| -------------- | ---------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ci-pr.yml` | PR → `stage`/`main` | service Postgres 17 (mínimo) | lint, format, typecheck, knip, cycles, unit → migrate → **seed:minimal** → int → **build** + `migration-lock` |
| `ci-stage.yml` | push em `stage` | `STAGE_DATABASE_URL` (Environment `stage`) | **só** `migrate` + smoke int (subset curado — a suíte completa é lenta demais contra Neon remoto e fica no ci-pr) — **NUNCA `pnpm build`** (build migra o banco que vê), nunca seed:minimal |
| `ci.yml` | push em `main` | service Postgres | gate pós-promote |
| E2E | fora do caminho crítico (nightly/label futura) | — | não bloqueia autonomia |
| Workflow | Trigger | Banco | Passos |
| -------------- | ------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ci-pr.yml` | PR → `stage`/`main` | service Postgres 17 (mínimo) | jobs paralelos: `lint`/`format`/`typecheck`/`knip`/`cycles` · `unit`/`int` (**afetados pelo diff**: `vitest --changed` vs base do PR; fallback full em paths de risco — `scripts/test-affected.mjs`) · `e2e` (manifesto path→spec — `scripts/e2e-affected.mjs`, bloqueante) · `build` + `migration-lock`; job rollup **`checks`** agrega todos (nome legado exigido pela branch protection — não re-serializa a suíte) |
| `ci-stage.yml` | push em `stage` | `STAGE_DATABASE_URL` (Environment `stage`) | **só** `migrate` + smoke int (subset curado — a suíte completa é lenta demais contra Neon remoto, estourou timeout 2× em 2026-07-30; a full fica no ci-pr/ci.yml) — **NUNCA `pnpm build`** (build migra o banco que vê), nunca seed:minimal |
| `ci.yml` | push em `main` | service Postgres | **gate completo sempre** (rede de segurança pós-promote): estático + unit + int full (com seed:minimal) + build + e2e full |
Fast gate local do agente antes do push: `pnpm lint && pnpm exec tsc --noEmit && pnpm test:unit` (+ `pnpm format:check` e `pnpm check:cycles` antes de commitar; int/build = CI).
Fast gate local do agente antes do push: `pnpm gate:fast` (lint + typecheck + test:unit); o hook `.husky/pre-push` roda `pnpm gate:push` (fast + format:check + check:cycles). Fonte única: scripts `gate:*` em `package.json`. knip fica fora do hook (grafo incompleto — ledger P3; CI o roda bloqueante). Escape para WIP: `git push --no-verify` (o CI repete tudo). **E2e afetado localmente (mesmo critério do PR):** `pnpm test:e2e:affected`. int/build/e2e = CI.
## Stage DB — runbook de refresh (semanal, humano)

View file

@ -13,6 +13,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas.
4. **Campaign LGPD consent texts are not yet configured in production.** Invites and self-service leadership flows fail closed until an admin creates `Consent.key = 'lideranca-autopreenchimento'` with counsel-approved copy. The C2 supporter registry (merged to `main`, production-blocked) additionally requires `Consent.key = 'apoiador-cadastro'` and `Consent.key = 'apoiador-intencao-voto'` (vote intention is sensitive data) — same fail-closed behavior. All four keys are bundled in the single legal batch of Onda 0 (see deploy checklist).
**Recently resolved (2026-07-30):** **CI/dev workflow + desbloqueio PR #50** — o PR #50 (B53) ficou BLOCKED por duas causas de infra empilhadas no job `migration-lock` (sem `actions/checkout` → `gh` sem `.git`; depois `GraphQL: Resource not accessible by integration` → `GITHUB_TOKEN` sem `pull-requests: read`) e por um spec int frágil ao `db:seed:minimal` (`homeSearchLeaderships` assertava conjunto global com nome fixo `'Lider'`, que casa word-start com `Seed Liderança Um` do seed). O incidente gerou: (a) política **"Dono do PR, dono do CI"** (`docs/AGENT-OPS.md`) — done = PR mergeado em stage com CI verde, falha fora da feature também é do dono; (b) `ci-pr.yml` reescrito em **jobs paralelos**; (c) **testes afetados pelo diff** — `scripts/test-affected.mjs` (`vitest --changed` vs base do PR) e `scripts/e2e-affected.mjs` + manifesto `scripts/lib/e2e-affected-manifest.mjs`, fallback full em paths de risco (núcleo puro em `scripts/lib/test-affected-core.mjs`, pins unit); e2e entra bloqueante no PR; (d) `ci.yml` (main) virou rede de segurança completa (seed:minimal + build + e2e full); stage segue smoke; (e) fast gate DRY: scripts `gate:fast`/`gate:push`, `.husky/pre-push` roda `gate:push` (knip fora, escape `--no-verify`).
**Recently resolved (2026-07-18):** the `/campanha` nuclei MVP shipped (auth/RBAC, nuclei, leaderships, vote estimates, updates, WhatsApp invites, dashboard) with consolidated migration `20260718_010733_consolidate_campaign_schema`. Later the same day, cycle 2 was merged and deployed: multi-municipality/neighborhood territory arrays (migration `20260718_190559_territorio_multi_municipio_bairro`), TSE 2022 election baseline collections + `pnpm db:seed:tse` (migration `20260718_195854_add_election_results`), the filtered nuclei-list overview panel, the nucleus share dialog, and the installable `/campanha` PWA. Still on 2026-07-18, **A2** shipped: static Bahia municipality↔TSE zone map (`src/lib/bahiaTseZones.ts` from TSE 2024 `detalhe_votacao_munzona` BA) plus opt-in territory/zone suggestion chips in the nucleus form (`territorySuggestions`, `NucleusTerritoryAndZonesFields`) — no migration, no forced equality on save. Also the same day: **C2** Cadastro nominal de apoiadores (`supporter` join `Contact`↔campanha with optional `nucleus`, migration `20260718_222656_add_supporter` with `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent keys `apoiador-cadastro` / `apoiador-intencao-voto` resolved via a generalized `campaignConsent.ts` that fails closed, `/campanha/apoiadores` vertical with list+KPIs/detail/CSV-import wizard; phone required in v1, `lideranca` has no access to the area; production use with real data still waits on Consent keys + legal sign-off). Also the same day: C3 Planos de Ação (`actionPlan` + `/campanha/planos`, migration `20260718_222832_add_action_plan`, blocos "Próximos eventos") — **renamed to `activity` / `/campanha/atividades` by C13 on 2026-07-25**; the old names survive only in frozen migration history. Also the same day: **B2** map foundation — static TopoJSON for Bahia municipalities + identity territories, `bahiaMunicipalityCodes`, helpers in `bahiaGeometries`, re-runnable `pnpm build:geometries` (no migration, no UI; Leaflet is B3). Post-`/simplify` scale follow-ups for that delivery are registered as **B5** (`docs/plans/escala-dry-pos-b2.md`). Earlier (2026-07-15): local Postgres via `docker-compose.yml` (Postgres 17, `teqo_test`); Payload migrations baselined; dev/test database guards; `post`/`tag` news routes deployed (see "Posts & Tags" below).
**Recently resolved (2026-07-18 evening / 2026-07-19):** **A4** Baseline no produto + Gap vs 2022 shipped and merged to `main` — `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` aggregate TSE 2022 by nucleus geography; `computeGapVs2022` + `NucleusInsights` on the overview tab; `NucleusElectoralBaseline` card (candidate / president / governor via `BASELINE_TICKET_2022`); "Baseline 2022" card on the filtered nuclei-list overview. No migration (reads existing A3 collections). A `/simplify` pass applied targeted cleanup (role-generic ticket config, per-city `zoneNumber in […]`, shared geography helpers). Scale/DRY debits larger than cleanup (aggregate federal ranking on detail instead of loading all nominal rows, filter by TSE `cityCode`, Alert `confirmed` + `Progress` reuse) are registered as **A7** (`docs/plans/escala-dry-pos-a4.md`).

View file

@ -8,7 +8,9 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse
- **Integration** (`tests/int/**/*.int.spec.ts`, 48 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
- **E2E** (`tests/e2e`, Playwright, 7 specs): smoke-level; most campaign behavior is pinned at the int layer.
CI (`.github/workflows/ci-pr.yml`) runs lint (zero warnings), typecheck, knip, unit, migrate, **`db:seed:minimal`**, int and build against a Postgres 17 service.
CI (`.github/workflows/ci-pr.yml`) runs parallel jobs — lint (zero warnings), format, typecheck, knip, cycles, unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths), e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; production build + `list`/`github` reporters in CI) and build — against a Postgres 17 service with **`db:seed:minimal`**. Push to `main` (`ci.yml`) always runs the full net including e2e.
**Local e2e before push (same blast radius as PR CI):** `pnpm test:e2e:affected` — runs `e2e-affected.mjs` against `origin/stage`, migrates/seeds, then `pnpm test:e2e` with the selected specs (or the full suite on high-risk diffs). Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode (after fixing the known municipalities-list prod client bug) with `E2E_PROD=1 pnpm test:e2e:affected`.
## Minimal test database (`pnpm db:seed:minimal`)

View file

@ -0,0 +1,90 @@
# CI paralela no PR — quebrar o job único do ci-pr em jobs com needs reais
Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano)
Atualizado em: 2026-07-30
Issue: —
Priority: P1
Model: composer-2.5
Impeccable: A — N/A (sem superfície UI)
Appetite: ~1 dia eng; 1 workflow reescrito + matriz documentada em AGENT-OPS
Responsável: —
## Dados → decisão → apresentação
Dados: N/A — infra de CI. A métrica de sucesso é wall-clock do workflow (ver critérios de aceite), lida no próprio GitHub Actions.
## Contexto
`.github/workflows/ci-pr.yml` roda **tudo em série num único job `checks`**: lint → format → tsc → knip → madge → unit → migrate → seed → int → build (último run do PR #50: 9m37s até falhar no int — e a falha só aparece no minuto ~9, depois de todos os gates baratos passarem). Cada falha de lint custa um ciclo completo de feedback. `.github/workflows/ci.yml` (main) replica o mesmo job único sem `seed:minimal`, sem `build` e sem e2e; `ci-stage.yml` roda só `migrate` + smoke int contra Neon (subset curado — a suíte completa estourou 30 min duas vezes em 2026-07-30 por latência, comentário no próprio workflow).
Decisão de produto (2026-07-30, brief do lote CI): jobs paralelizáveis com `needs` só onde há dependência real; push em `stage` e `main` roda suíte **completa** como rede de segurança.
## Objetivos
- `ci-pr.yml` vira N jobs paralelos; feedback de lint/format/typecheck em < ~3 min.
- Dependências reais explícitas: só `int` e `build` precisam do Postgres service; `build` pode `needs: [int]` (ou rodar contra o próprio service — decidir na implementação por tempo medido).
- Matriz **PR vs push stage vs push main** documentada em `docs/AGENT-OPS.md` (tabela "CI por alvo").
- Invariantes preservados: `pnpm build` nunca perto de `STAGE_DATABASE_URL`; comandos bare; `migration-lock` intacto (com o fix de checkout do OPS3).
## Decisões travadas
- **Stage continua smoke (migrate + subset curado), não suíte completa.** A suíte int completa contra Neon remoto estourou o timeout duas vezes (registrado no comentário de `ci-stage.yml:22-30`); a rede de segurança completa fica no ci-pr (DB mínimo) e no ci.yml (main). **Rejeitado:** full int no stage (latência ~120 ms/query — inviável, já medido); dropar o smoke do stage (é a única verificação contra snapshot real de prod).
- **`ci.yml` (main) ganha `db:seed:minimal` + `build` + int completo, espelhando o ci-pr.** Hoje main não roda seed/build/e2e; como o promote PR já passou no ci-pr, o ci.yml é rede de segurança contra drift de merge — barato (service DB local). **Rejeitado:** manter main minimal (a tensão do brief fica sem resolução); build contra qualquer URL remota (proibido).
- **E2E entra no ci-pr como job afetado por manifesto (OPS5), bloqueante; stage não roda e2e; main roda full e2e.** Detalhes e fallback no plano OPS5. **Rejeitado:** e2e fora do caminho crítico para sempre (o "fora do caminho crítico" do AGENT-OPS era placeholder "nightly/label futura" — este lote é a futura); full e2e em todo PR (custo de boot do dev server por PR sem relação com o diff).
- **Uma estrutura de jobs compartilhada via YAML anchors/reusable steps, sem reusable-workflow separado.** **Rejeitado:** extrair `.github/workflows/_reusable.yml` (cerimônia para 2 consumidores; revisitado quando o 3º workflow precisar da mesma cadeia).
## Questões em aberto
- **`build` precisa de `needs: [int]`?** **Opções:** A) build paralelo ao int (cada um com seu service Postgres) | B) build só após int verde. **Recomendação:** A — build não depende do resultado do int; paralelo corta o caminho crítico. Se medir contenção de runner, cair para B é mudança de uma linha.
- **Cache de pnpm/Next entre jobs.** **Opções:** A) cada job instala (cache pnpm já existe) | B) artefato de `node_modules` compartilhado. **Recomendação:** A — upload/download de artefato costuma perder para o cache pnpm; medir no primeiro run.
## Abordagem proposta
```mermaid
flowchart LR
subgraph pr [ci-pr.yml]
lint[lint] --- fmt[format] --- tsc[typecheck] --- knip --- madge[cycles] --- unit
intjob["int (service PG: migrate+seed)"] --- buildjob["build (service PG)"]
mlock[migration-lock]
end
subgraph main [ci.yml]
full[gate completo + seed + build + e2e full]
end
subgraph stage [ci-stage.yml]
smoke[migrate + smoke int subset]
end
```
Componentes:
- **`.github/workflows/ci-pr.yml`** — quebrar `checks` em jobs: `lint`, `format`, `typecheck`, `knip`, `cycles`, `unit` (sem service), `int` (com service Postgres 17 + migrate + seed:minimal), `build` (com service), `migration-lock` (inalterado salvo fix OPS3). Sem `needs` entre os baratos.
- **`.github/workflows/ci.yml`** — adicionar steps `db:seed:minimal` e `build`; preparar para receber e2e full (OPS5).
- **`docs/AGENT-OPS.md`** — atualizar tabela "CI por alvo" com a matriz final (incl. e2e por alvo) e o tempo-alvo de feedback.
- Sem migration, sem código de app.
## Dependências
- **OPS3** (migration-lock funcionando) — dura, senão o CI fica vermelho por infra no PR desta entrega.
- OPS5 (affected tests) depende desta — a seleção vira flags nos jobs `int`/`e2e` aqui criados.
## Não escopo
- Manifesto e2e / `--changed` do vitest → OPS5.
- Hooks locais (`gate:fast`/`gate:push`) → OPS6. Docs de política de agente → OPS7.
- Nightly e2e full agendado → Adiado (abaixo).
## Rabbit holes
- **Reusable workflows / composite actions.** Cerimônia YAML sem 3º consumidor. **Mitigação:** decisão travada acima; gatilho de revisitação registrado.
- **Shard da suíte int em N workers.** O gargalo medido é serialização, não CPU de um job; sharding adiciona setup de DB por shard. **Mitigação:** adiado com gatilho (int > 15 min no job único por 2 semanas).
## Adiado com gatilho
- **Nightly e2e full + shard de int.** Revisitar quando: job `int` > 15 min sustentado ou e2e full no ci.yml > 25 min.
## Referências
- `.github/workflows/ci-pr.yml` (job único atual), `ci.yml`, `ci-stage.yml` (comentário do subset)
- `docs/AGENT-OPS.md` — tabela "CI por alvo" e "Contrato de PR"
- AGENTS.md / `.cursor/rules/engineering-standards.mdc` — gate em duas velocidades, comandos bare
- Run 30558184687 — baseline de wall-clock (9m37s serial até a falha)

View file

@ -0,0 +1,77 @@
# Desbloquear PR #50 — fix migration-lock + spec homeSearchLeaderships seed-proof
Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano)
Atualizado em: 2026-07-30
Issue: —
Priority: P0
Model: kimi-k2.7-code
Impeccable: A — N/A (sem superfície UI)
Appetite: ~0,5 dia eng; 1 workflow step + 1 repair de spec int + watch de CI
Responsável: —
## Dados → decisão → apresentação
Dados: N/A — repair de CI/teste, sem superfície de dados.
## Contexto
O PR [#50](https://github.com/fsolla/teqo/pull/50) (`agent/B53-busca-global-demandas`, Issue #7, B53) está `MERGEABLE` mas `BLOCKED` com dois checks vermelhos (run [30558184687](https://github.com/fsolla/teqo/actions/runs/30558184687)):
1. **`migration-lock` FAIL** — duas causas de infra empilhadas no job em `.github/workflows/ci-pr.yml`: (a) `gh pr list --json number,files` **sem `actions/checkout@v4`** → `failed to run git: fatal: not a git repository`; (b) com o checkout, `GraphQL: Resource not accessible by integration (repository.pullRequests)` → o `GITHUB_TOKEN` padrão precisa de `permissions: pull-requests: read` explícita. Todo PR falhava esse job.
2. **`checks` FAIL** — `tests/int/homeSearchLeaderships.int.spec.ts:87`: `expected [ 1, 168, 169 ] to deeply equal [ 168, 169 ]`. O spec consulta `'Lider'` e asserta o conjunto global exato; em ci-pr, `pnpm db:seed:minimal` roda antes do int e semeia `Seed Liderança Um` (`scripts/lib/seed-minimal-manifest.mjs:103`) — word-start `Liderança` casa `Lider`. Em `ci.yml` (main) não há `seed:minimal`, então o defeito só aparece no ci-pr: **todo PR futuro contra `stage` está bloqueado**, não só o #50. O spec de Demandas do próprio PR (`tests/int/homeSearchDemands.int.spec.ts:76`) já usa o padrão correto: token único `fixtures.value('scope-demanda')`.
Decisão de produto (2026-07-30, brief do lote CI): done = **PR #50 mergeado em `stage` com CI verde**, não "feature implementada".
## Objetivos
- `gh pr checks 50` todo verde e merge automático em `stage` efetivado.
- `migration-lock` volta a exercer a regra real (≤1 PR tocando schema) em vez de falhar por infra.
- `homeSearchLeaderships.int.spec.ts` fica seed-proof: passa com e sem `db:seed:minimal` no banco.
- Sem migration, sem schema, sem mudança de comportamento de produto.
## Decisões travadas
- **Fix do spec no padrão token-único, não filtro-por-ids nem exclusão do seed.** O padrão `fixtures.value(...)` já existe no spec irmão (demandas) e mantém a asserção de conjunto exato (que pega regressão de escopo). **Rejeitado:** filtrar o resultado aos ids criados pela fixture (enfraquece a asserção de escopo do coordinator); remover as lideranças do `seed-minimal-manifest` (o contrato do DB mínimo — pinado por `tests/unit/seedMinimalManifest.unit.spec.ts` — existe para int specs que dependem de seed; quebraria outros consumidores).
- **Fix do `migration-lock` = `actions/checkout@v4` + `permissions: { contents: read, pull-requests: read }` no job.** As duas causas foram confirmadas em runs consecutivos (log do run 30561456683: GraphQL permission error após o checkout). **Rejeitado:** token dedicado/PAT (GITHUB_TOKEN com grant explícito é o padrão e não adiciona secret).
- **Fix vai na branch do próprio PR #50** (`agent/B53-busca-global-demandas`), não em branch separada: a política "CI vermelho = seu problema" (OPS7) começa valendo aqui. **Rejeitado:** PR separado de infra primeiro (duplo round-trip de CI; o #50 já é o PR afetado e está `MERGEABLE`).
## Questões em aberto
- **O spec `homeSearchStateDeputies`/`homeSearchAdvisors` etc. têm o mesmo padrão frágil?** **Opções:** A) auditar e corrigir todos neste PR | B) corrigir só o que falha e registrar o resto como débito. **Recomendação:** A se o audit mostrar o mesmo padrão de nome fixo + assert global (grep por `toEqual([` em `homeSearch*.int.spec.ts`) — custo marginal dentro do appetite, evita o próximo bloqueio; senão B.
## Abordagem proposta
Componentes:
- **`.github/workflows/ci-pr.yml`** — job `migration-lock`: inserir `- uses: actions/checkout@v4` como primeiro step.
- **`tests/int/homeSearchLeaderships.int.spec.ts`** — trocar nomes fixos (`Lider Cairu`, `Lider Feira`, `Zeca Pagodinho`, `Maria Silva`) por tokens únicos via `fixtures.value(...)` e usar o token como query (espelhando `homeSearchDemands.int.spec.ts:76-94`); manter asserts de conjunto exato.
- **Audit grep** — `rg "toEqual\(\[" tests/int/homeSearch*.int.spec.ts` para a questão em aberto acima.
Sem migration, sem collection, sem server action.
## Dependências
Nenhuma de outro plano. Reusa `installCampaignFixtures` (`tests/helpers/campaignFixtures.ts`) e o padrão `fixtures.value` já existente.
## Não escopo
- Refatoração estrutural do ci-pr (jobs paralelos) → OPS4.
- Affected tests / manifesto e2e → OPS5.
- Pre-push hooks → OPS6. Política de agente em docs → OPS7 (texto novo; a **prática** vale neste PR).
## Rabbit holes
- **"Já que toco o workflow, paralelizo logo".** Explode OPS4 dentro do P0 e atrasa o desbloqueio. **Mitigação:** diff do workflow limitado ao step de checkout.
- **Investigar por que `ci-stage` smoke não pegou isso.** O smoke roda contra Neon com subset curado; a falha é do ambiente mínimo. **Mitigação:** registrar observação no plano OPS4, não investigar aqui.
## Adiado com gatilho
Nenhum neste item.
## Referências
- GitHub Issue #7 (B53), PR #50, run 30558184687 (logs das duas falhas)
- `.github/workflows/ci-pr.yml` — job `migration-lock`
- `tests/int/homeSearchLeaderships.int.spec.ts` + `tests/int/homeSearchDemands.int.spec.ts` (padrão-alvo)
- `scripts/lib/seed-minimal-manifest.mjs` — `MINIMAL_LEADERSHIPS`
- `.cursor/skills/work-issue/SKILL.md` — Passo 6 (acompanhar checks até o merge)

View file

@ -0,0 +1,73 @@
# Gate local DRY — gate:fast / gate:push + pre-push completo
Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano)
Atualizado em: 2026-07-30
Issue: —
Priority: P2
Model: cursor-grok-4.5-high
Impeccable: A — N/A (sem superfície UI)
Appetite: ~0,5 dia eng; 2 scripts npm + 1 hook + docs
Responsável: —
## Dados → decisão → apresentação
Dados: N/A — chore de DX.
## Contexto
`.husky/pre-push` é uma linha: `pnpm lint && pnpm format:check && pnpm typecheck`. Mas o fast gate exigido por `.cursor/rules/engineering-standards.mdc` (desde 2026-07-30) e por `work-issue` Passo 4.3 é **lint + tsc + test:unit + format:check + check:cycles**. Resultado: agentes e humanos pusham verde no hook e quebram no CI por unit/cycles que nunca rodaram localmente. Não existe pre-commit hook.
Decisão de produto (2026-07-30, brief do lote CI): scripts DRY (`gate:fast`, `gate:push`), divisão pre-commit vs pre-push explícita, escape hatch documentado.
## Objetivos
- `.husky/pre-push` roda o fast gate completo via script npm único (DRY com a documentação).
- `package.json`: `gate:fast` (lint + typecheck + test:unit) e `gate:push` (gate:fast + format:check + check:cycles).
- Escape hatch documentado (`git push --no-verify`), com aviso de que CI repete tudo.
- Docs (`engineering-standards.mdc`, `AGENT-OPS.md`) citam os scripts, não a lista de comandos — uma fonte só.
## Decisões travadas
- **knip fora do pre-push.** O grafo do knip é incompleto (não carrega `payload.config.ts`, ledger P3) e é lento para um hook; CI já o roda como erro bloqueante. **Rejeitado:** knip no hook (custo por push + falsos positivos já fichados); knip no `gate:fast` documentado como obrigatório (mesma razão).
- **Sem pre-commit hook.** A divisão fica: nada no commit (commits intermediários de WIP são legítimos), tudo no push. **Rejeitado:** lint-staged no commit (adiciona toolchain e atrito sem pegar classe de erro que o push não pegue segundos depois).
- **Escape hatch = `--no-verify` nativo, documentado; sem variável mágica própria.** **Rejeitado:** `SKIP_GATE=1` (mais uma convenção para manter; `--no-verify` é o padrão git que todo agente/humano já conhece).
- **Hook chama `pnpm gate:push`, nunca inline de comandos.** Uma fonte de verdade editável em `package.json`. **Rejeitado:** manter a linha de shell no hook (foi o que causou o drift atual).
## Questões em aberto
- **`pnpm format` auto-fix no hook em vez de `format:check`?** **Opções:** A) check falha e o autor roda `pnpm format` | B) hook formata e aborta para re-add. **Recomendação:** A — hook que edita a worktree surpreende; o erro do check já diz o remédio.
## Abordagem proposta
Componentes:
- **`package.json`** — scripts `gate:fast` e `gate:push` (comandos bare encadeados com `&&`, nunca piped — invariante do repo).
- **`.husky/pre-push`** — `pnpm gate:push`.
- **`.cursor/rules/engineering-standards.mdc`** — item 3 do "After every change" passa a citar `pnpm gate:fast` / `pnpm gate:push`.
- **`docs/AGENT-OPS.md`** — linha do fast gate local atualizada para os scripts.
- **`.cursor/skills/work-issue/SKILL.md`** — Passo 4.3/6.1 cita `pnpm gate:push`.
Sem migration, sem código de app.
## Dependências
Nenhuma de outro plano (independente; pode rodar em paralelo com OPS3/OPS4). Conflito potencial de merge com OPS7 em `AGENT-OPS.md`/`engineering-standards.mdc` — serializar na fila ou aceitar rebase trivial.
## Não escopo
- Mudar o que o CI roda → OPS4/OPS5. Política "CI vermelho = seu problema" → OPS7.
- Husky v9 deprecations / migração de tooling de hooks.
## Rabbit holes
- **"Já que toco hooks, adiciono lint-staged/commitlint".** Toolchain nova para problema que não temos. **Mitigação:** decisão travada; commitlint só se 3 mensagens fora-do-padrão quebrarem automação (nenhuma hoje).
## Adiado com gatilho
Nenhum neste item.
## Referências
- `.husky/pre-push` (linha atual), `package.json` (scripts de gate existentes)
- `.cursor/rules/engineering-standards.mdc` — "Gate em duas velocidades"
- `docs/AGENT-OPS.md` — fast gate local; `.cursor/skills/work-issue/SKILL.md` — Passo 4.3

View file

@ -0,0 +1,75 @@
# Política de agente — CI vermelho é do dono do PR até o merge
Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano)
Atualizado em: 2026-07-30
Issue: —
Priority: P2
Model: cursor-grok-4.5-high
Impeccable: A — N/A (sem superfície UI)
Appetite: ~0,5 dia; docs em 3 arquivos
Responsável: —
## Dados → decisão → apresentação
Dados: N/A — política de fluxo em docs.
## Contexto
Incidente (2026-07-30, PR #50): o agente em `work-issue` desistiu ao ver (a) `migration-lock` falhando por defeito de infra (job sem `checkout`) e (b) um teste int falhando fora do escopo nominal da feature (`homeSearchLeaderships`, confundido com e2e). A feature estava pronta; o PR ficou `BLOCKED` e ninguém era dono. Lacuna: nem `work-issue` nem `engineering-standards` dizem quem conserta CI quebrado "fora da feature".
Decisão de produto (2026-07-30, brief do lote CI): **done = PR mergeado em `stage` com CI verde** — corrigir qualquer falha de CI necessária ao merge, mesmo fora da feature.
## Objetivos
- `work-issue`, `engineering-standards.mdc` e `docs/AGENT-OPS.md` declaram a política com o mesmo texto-núcleo (uma fonte, duas citações).
- Regra de triagem da falha de CI documentada: infra do workflow / teste pré-existente / regressão da feature — as três são do dono do PR; o que muda é o fix.
- Registro do incidente #50 como precedente em `docs/CHANGELOG-AGENTS.md` (uma entrada curta, formato do arquivo).
## Decisões travadas
- **Dono do PR conserta, sem exceção por escopo.** Racional: com agentes paralelos, "não é minha feature" vira deadlock (o #50 ficou parado por isso). O autor já tem branch, contexto e CI loop aberto. **Rejeitado:** abrir Issue separada e seguir (o PR apodrece BLOCKED — exatamente o incidente); esperar humano (derrota a autonomia do paradigma AGENT-OPS).
- **Limite explícito: o fix colateral segue as mesmas regras de blast radius** — migration/access/Consent continuam proibidos de "consertar de passagem" sem plano; nesses casos o agente para e escala (Opções documentadas). **Rejeitado:** carta branca para qualquer fix (contradiz as invariantes de segurança do AGENTS.md).
- **Uma fonte de verdade (AGENT-OPS), citações nos outros dois** — evita o drift que o pre-push sofreu (OPS6). **Rejeitado:** repetir o parágrafo inteiro nos três arquivos.
## Questões em aberto
- **Teste flaky (não determinístico) conta como "sua responsabilidade"?** **Opções:** A) sim, repair ou quarentena com justificativa no PR | B) retry até passar. **Recomendação:** A — retry cega o CI para flakes reais; quarentena exige comentário + débito registrado via `capture-review-debts`.
## Abordagem proposta
Componentes (texto, não código):
- **`docs/AGENT-OPS.md`** — novo bloco na seção "Fluxo": "Dono do PR, dono do CI" (definição de done, triagem das 3 classes de falha, limite de blast radius, flaky→quarentena+débito).
- **`.cursor/skills/work-issue/SKILL.md`** — Passo 6.5 reforçado: falha no ci-pr → corrige na mesma branch **qualquer que seja a origem**, citando AGENT-OPS; "fora do escopo" não é critério de parada.
- **`.cursor/rules/engineering-standards.mdc`** — linha no "After every change": CI vermelho no seu PR é seu, mesmo pré-existente.
- **`docs/CHANGELOG-AGENTS.md`** — entrada do incidente PR #50 → esta política.
Texto-núcleo proposto (núcleo único citado nos três):
> Done = PR mergeado em `stage` com CI verde. Falha de CI no teu PR — infra do workflow, teste pré-existente ou regressão da feature — é tua até o merge. "Fora do escopo da feature" não é motivo para parar: corrige na mesma branch. Exceção de blast radius: se o fix exigir migration, access control ou Consent, para e escala com Opções — esses nunca se corrigem "de passagem". Flaky não se resolve com retry: repair ou quarentena justificada + débito registrado.
Sem migration, sem código de app.
## Dependências
Nenhuma dura. Sinérgica com OPS3 (que aplica a política na prática) e OPS6 (mesma família de arquivos — serializar para evitar conflito trivial).
## Não escopo
- Mudança de gates/hooks → OPS6. Refatoração de workflows → OPS4/OPS5.
- `agent:file-miss` do incidente: o incidente vira precedente documentado aqui; se o humano quiser também como `kind:agent-miss` para o harvest OPS2 (#43), registrar lá.
## Rabbit holes
- **Reescrever work-issue inteira "de passagem".** A skill está estável; o delta é uma política. **Mitigação:** diff limitado ao Passo 6 + citação.
## Adiado com gatilho
Nenhum neste item.
## Referências
- Incidente: PR #50, run 30558184687 (migration-lock sem checkout; int fora da feature)
- `docs/AGENT-OPS.md` — seção "Fluxo"; `.cursor/skills/work-issue/SKILL.md` — Passo 6
- `.cursor/rules/engineering-standards.mdc` — "After every change"
- Issue #43 (OPS2) — harvest agent-miss → guardrails

View file

@ -0,0 +1,94 @@
# Testes afetados pelo diff no PR — vitest --changed + manifesto path→spec e2e
Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano)
Atualizado em: 2026-07-30
Issue: —
Priority: P1
Model: composer-2.5
Impeccable: A — N/A (sem superfície UI)
Appetite: ~1–2 dias eng; script de seleção + manifesto + wiring nos jobs do OPS4 + pins
Responsável: —
## Dados → decisão → apresentação
Dados: N/A — infra de CI.
## Contexto
Com o ci-pr paralelizado (OPS4), o próximo corte é rodar **só o que o diff toca** em PRs de feature branch: hoje um PR de copy roda 523 testes int + 522 unit + build. Vitest suporta `--changed` contra uma base git; Playwright `--only-changed` **não** cobre mudanças em `src/` (specs e2e não importam o app), então a seleção e2e precisa de um **manifesto path → spec** curado no repo. Push em `stage`/`main` sempre roda suíte completa (decisão OPS4).
Decisão de produto (2026-07-30, brief do lote CI): fallback para suíte completa quando o diff tocar paths de alto risco — migrations, lockfile, configs de teste, seed manifest, collections/globals.
## Objetivos
- Job `unit`/`int` no ci-pr roda `vitest --changed origin/$GITHUB_BASE_REF` (fallback full nos paths de risco).
- Job `e2e` no ci-pr roda specs mapeadas pelo manifesto; fallback full idem.
- `scripts/e2e-affected.mjs` (ou equivalente) imprime a lista de specs e o motivo (decisão auditável no log do CI).
- Pins: unit test do manifesto (path→spec existe, spec existe no disco) + comportamento de fallback.
## Decisões travadas
- **Manifesto e2e = dados no repo, não inferência por import.** e2e não importa `src/`; a única fonte de verdade é uma tabela curada. **Rejeitado:** heurística por nome de spec (frágil — `campaignZoneMap` ≠ pasta `map/`); trace estático Playwright→app (não existe aresta de import).
- **Fallback full em paths de alto risco** (lista congelada no script): `src/migrations/`, `src/payload-types.ts`, `pnpm-lock.yaml`, `package.json`, `vitest*.config.*`, `playwright.config.ts`, `scripts/lib/seed-minimal-manifest.mjs`, `scripts/seed-minimal.mjs`, `src/collections/`, `src/globals/`, `src/payload.config.ts`, `tests/helpers/`, `tests/e2e/fixtures/`, `.env*`. **Rejeitado:** fallback por "mais de N arquivos" (tamanho ≠ risco); lista aberta por glob no workflow (regra fora de teste unitário).
- **Seleção só no ci-pr; `ci.yml`/`ci-stage.yml` sempre full.** Rede de segurança por alvo (decisão OPS4). **Rejeitado:** affected também no main (derrota o propósito do gate pós-merge).
- **`--changed` com fallback para full quando vitest retorna conjunto vazio mas o diff toca `src/`** (ex.: arquivo novo sem spec importando). **Rejeitado:** aceitar conjunto vazio como "nada a rodar" (falso verde em código novo sem teste ligado).
## Questões em aberto
- **e2e bloqueante no PR desde o dia 1?** **Decidido no gate (humano, 2026-07-30): bloqueante já.** A alternativa de 2 semanas em `continue-on-error` para calibrar o manifesto foi rejeitada pelo humano ("já resolve logo nessa sessão"). O manifesto fica visível nos logs (`unmapped`) para calibração contínua.
## Abordagem proposta
```mermaid
flowchart LR
diff["git diff origin/base...HEAD"] --> script["scripts/e2e-affected.mjs + scripts/test-affected.mjs"]
script -->|paths de risco| full[suites completas]
script -->|paths mapeados| sel["specs selecionadas + vitest --changed"]
sel --> jobs["jobs unit/int/e2e do ci-pr"]
full --> jobs
```
Componentes:
- **`scripts/test-affected.mjs`** — computa diff vs `GITHUB_BASE_REF` (default `origin/stage`), aplica a lista de risco, emite `{ mode: 'full' | 'changed' }` + args vitest. Consumido pelos jobs `unit` e `int`.
- **`scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`** — tabela prefixo→specs. Esboço inicial (17 specs em `tests/e2e/`):
- `src/app/(campaign)/campanha/(app)/municipios`, `src/components/campaign/municipality`, `src/utilities/municipality/` → `campaignMunicipalities`, `campaignZoneMap`, `campaignNearestMunicipality`, `campaignSavedFilters`, `campaignColumnPicker`
- `…/atividades`, `components/campaign/activity` → `campaignActivity`
- `…/liderancas`, `components/campaign/leadership` → `campaignLeaderships`
- `…/territorios`, `components/campaign/territory` → `campaignTerritories`
- `campanha/login`, `utilities/campaignAuth`, `utilities/webauthn/` → `campaignAuth`, `campaignWebAuthn`
- `…/conceitos` → `campaignConcepts`; dashboard/home → `campaignHomeActions`
- `components/campaign/shell`, PWA → `campaign-pwa`, `campaignWizardChrome`
- `src/app/(payload)` → `admin`; `src/app/(frontend)` → `frontend`
- qualquer outro `src/` não mapeado → log explícito "sem spec mapeada" (modo e2e: nenhuma; o gap fica visível no log para o humano julgar — decisão: não falhar, e2e é camada fina sobre int).
- **`.github/workflows/ci-pr.yml`** — jobs `unit`/`int`/`e2e` (do OPS4) consomem os scripts; step de diff com `fetch-depth: 0` no checkout.
- **Tests** — `tests/unit/testAffected.unit.spec.ts` + `tests/unit/e2eAffectedManifest.unit.spec.ts` (manifesto aponta para specs existentes; paths de risco disparam full).
Sem migration, sem código de app.
## Dependências
- **OPS4** (jobs paralelos existem para receber a seleção) — dura.
- Reusa: padrão `scripts/lib/` (B5 F2), `tests/helpers/assertTestDatabase.ts` não é tocado.
## Não escopo
- Seleção de unit por manifesto (vitest `--changed` resolve por import graph real).
- Cache de resultados de teste entre runs (Turborepo-style) — sem evidência de necessidade.
- Nightly full e2e → OPS4 (adiado lá).
## Rabbit holes
- **Manifesto vira segundo grafo de dependências.** Se alguém "só completar" granularidade por arquivo, vira manutenção morta. **Mitigação:** granularidade de prefixo de domínio; pin de unit test só na existência, não na completude.
- **`vitest --changed` com worktrees/forks sem base fetchada.** `fetch-depth: 0` no checkout; fallback full se o merge-base não resolver (log + exit code do script).
## Adiado com gatilho
- **Completude do manifesto enforced (falhar CI quando `src/` novo não mapeia).** Revisitar quando: 3º fallback full causado por prefixo ausente.
## Referências
- `tests/e2e/` (17 specs + fixtures), `playwright.config.ts`, `vitest.unit.config.mts`, `vitest.config.mts`
- `.github/workflows/ci-pr.yml` (jobs do OPS4)
- `docs/AGENT-OPS.md` — linha E2E da tabela "CI por alvo" (atualizar neste item)
- Vitest `--changed`: https://vitest.dev/guide/cli.html#changed

View file

@ -46,6 +46,8 @@
"devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"format": "prettier --write .",
"format:check": "prettier --check .",
"gate:fast": "pnpm lint && pnpm typecheck && pnpm test:unit",
"gate:push": "pnpm gate:fast && pnpm format:check && pnpm check:cycles",
"check:cycles": "madge --circular --extensions ts,tsx --ts-config tsconfig.json src",
"generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:types",
@ -61,6 +63,7 @@
"test": "pnpm run test:unit && pnpm run test:int",
"test:all": "pnpm run test && pnpm run test:e2e",
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server --import=tsx/esm\" playwright test --config=playwright.config.ts",
"test:e2e:affected": "node scripts/run-e2e-affected.mjs",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts",
"test:unit": "cross-env NODE_OPTIONS=--no-deprecation DATABASE_URL=postgresql://invalid:invalid@127.0.0.1:1/unit_tests_must_not_connect vitest run --config ./vitest.unit.config.mts",
"typecheck": "tsc --noEmit",

View file

@ -44,7 +44,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
reporter: process.env.CI ? [['list'], ['github'], ['html', { open: 'never' }]] : 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
@ -90,7 +90,16 @@ export default defineConfig({
},
],
webServer: {
command: 'pnpm dev',
/*
* CI and E2E_PROD=1 serve the PRODUCTION build (`pnpm start` after a
* `pnpm build` step in the e2e job): dev-mode cold webpack compiles per
* route plus dev-server memory restarts made the full suite blow the 30-min
* job timeout on CI (measured 2026-07-30). The municipalities-list React
* #130 was fixed by sharing one Drawer across the mobile cards
* (CampaignListSheetProvider, 2026-07-30). Locally `pnpm dev` stays the
* default: no build wait and hot reload while writing specs.
*/
command: process.env.CI || process.env.E2E_PROD === '1' ? 'pnpm start' : 'pnpm dev',
/*
* Never reuse a dev server a developer may already have running against the
* production database — always start a fresh one bound to the test DB.

53
scripts/e2e-affected.mjs Normal file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* e2e-affected (OPS5) — decides the Playwright scope for a PR diff via the
* curated manifest and prints JSON on stdout:
* { mode, specs, reason, unmapped, base }.
*
* mode=full → run the whole e2e suite
* mode=selected → run tests/e2e/<name>.e2e.spec.ts for each name in `specs`
* mode=none → skip e2e for this diff
*
* `unmapped` src/ files go to stderr as a warning — never a failure (e2e is a
* thin layer over int; a human judges whether the manifest needs the prefix).
*/
import { execFileSync } from 'node:child_process'
import { E2E_AFFECTED_MANIFEST } from './lib/e2e-affected-manifest.mjs'
import { selectE2eSpecs } from './lib/test-affected-core.mjs'
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'stage'}`
let mergeBase
try {
mergeBase = execFileSync('git', ['merge-base', 'HEAD', base], { encoding: 'utf8' }).trim()
} catch {
const result = {
mode: 'full',
specs: [],
reason: `no merge-base with ${base} (shallow clone?)`,
unmapped: [],
base,
}
console.log(JSON.stringify(result))
process.exit(0)
}
const raw = execFileSync('git', ['diff', '--name-status', `${mergeBase}...HEAD`], {
encoding: 'utf8',
})
const files = raw
.split('\n')
.filter(Boolean)
.map((line) => {
const [status, path] = line.split('\t')
return { status, path }
})
const result = { ...selectE2eSpecs(files, E2E_AFFECTED_MANIFEST), base }
if (result.unmapped.length > 0) {
console.error(
`[e2e-affected] src/ paths with no manifest mapping:\n ${result.unmapped.join('\n ')}`,
)
}
console.log(JSON.stringify(result))

View file

@ -0,0 +1,85 @@
/**
* e2e manifest (OPS5): source path prefix → Playwright specs exercising it.
* e2e specs never import app code, so this curated table is the only source
* of truth. Granularity is the DOMAIN prefix on purpose — finer grains rot.
* Spec names exist as tests/e2e/<name>.e2e.spec.ts (pinned by unit test).
* Prefixes are repo-relative POSIX paths; matching is startsWith.
*/
const CAMPAIGN_APP = 'src/app/(campaign)/campanha/(app)'
export const E2E_AFFECTED_MANIFEST = [
{
prefixes: ['src/app/(payload)'],
specs: ['admin'],
},
{
prefixes: ['src/app/(frontend)'],
specs: ['frontend'],
},
{
prefixes: ['src/app/(campaign)/campanha/login', 'src/utilities/campaignAuth'],
specs: ['campaignAuth'],
},
{
prefixes: ['src/app/(campaign)/campanha/webauthn', 'src/utilities/webauthn'],
specs: ['campaignWebAuthn'],
},
{
prefixes: [
`${CAMPAIGN_APP}/municipios`,
'src/components/campaign/municipality',
'src/components/campaign/map',
'src/utilities/municipality',
],
specs: [
'campaignMunicipalities',
'campaignZoneMap',
'campaignNearestMunicipality',
'campaignSavedFilters',
'campaignColumnPicker',
],
},
{
prefixes: [
`${CAMPAIGN_APP}/atividades`,
'src/components/campaign/activity',
'src/utilities/activity',
],
specs: ['campaignActivity'],
},
{
prefixes: [
`${CAMPAIGN_APP}/liderancas`,
'src/components/campaign/leadership',
'src/utilities/leadership',
],
specs: ['campaignLeaderships'],
},
{
prefixes: [`${CAMPAIGN_APP}/territorios`, 'src/utilities/territory'],
specs: ['campaignTerritories'],
},
{
prefixes: [`${CAMPAIGN_APP}/conceitos`],
specs: ['campaignConcepts'],
},
{
prefixes: [
`${CAMPAIGN_APP}/page.tsx`,
`${CAMPAIGN_APP}/home-search`,
'src/components/campaign/dashboard',
'src/utilities/homeSearch',
'src/utilities/campaignDashboardData',
'src/lib/campaignHomeSearchHits',
],
specs: ['campaignHomeActions'],
},
{
prefixes: [
'src/components/campaign/shell',
`${CAMPAIGN_APP}/layout.tsx`,
'src/utilities/campaignPwa',
],
specs: ['campaign-pwa', 'campaignWizardChrome'],
},
]

View file

@ -0,0 +1,115 @@
/**
* Pure selection logic for affected-test CI jobs (OPS5). No git, no fs, no
* process — the CLI wrappers feed it the changed file list and it decides.
* Unit-pinned in tests/unit/testAffected.unit.spec.ts and
* tests/unit/e2eAffectedManifest.unit.spec.ts.
*/
/**
* Paths whose blast radius is the whole app: schema, test harness, lockfile.
* Any diff touching them runs the FULL suites — selection is unsafe there.
*/
export const HIGH_RISK_EXACT = new Set([
'package.json',
'pnpm-lock.yaml',
'playwright.config.ts',
'vitest.config.mts',
'vitest.unit.config.mts',
'vitest.setup.ts',
'src/payload-types.ts',
'src/payload.config.ts',
'scripts/seed-minimal.mjs',
'scripts/lib/seed-minimal-manifest.mjs',
'.env.test',
])
export const HIGH_RISK_PREFIXES = [
'src/migrations/',
'src/collections/',
'src/globals/',
'tests/helpers/',
'tests/e2e/fixtures/',
]
function isHighRisk(path) {
return HIGH_RISK_EXACT.has(path) || HIGH_RISK_PREFIXES.some((prefix) => path.startsWith(prefix))
}
/**
* Decide vitest scope for a PR diff.
* @param {{ path: string, status: string }[]} files git diff --name-status rows.
* @returns {{ mode: 'full' | 'changed' | 'none', reason: string }}
* full — high-risk path or new src file (nothing imports it yet, so
* --changed cannot find it; silent green otherwise);
* changed — vitest --changed <base> is meaningful;
* none — no src/tests touched (docs-only PR); skip the jobs.
*/
export function classifyTestScope(files) {
if (files.some(({ path }) => isHighRisk(path))) {
return { mode: 'full', reason: 'diff touches a high-risk path (schema/lockfile/test harness)' }
}
if (files.some(({ path, status }) => status === 'A' && path.startsWith('src/'))) {
return { mode: 'full', reason: 'new src/ file — vitest --changed cannot trace tests to it yet' }
}
if (files.some(({ path }) => path.startsWith('src/') || path.startsWith('tests/'))) {
return { mode: 'changed', reason: 'src/tests diff without high-risk paths' }
}
return { mode: 'none', reason: 'no src/ or tests/ changes' }
}
const E2E_SPEC_PATTERN = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/
/**
* Decide the Playwright scope for a PR diff.
* @param {{ path: string, status: string }[]} files
* @param {{ prefixes: string[], specs: string[] }[]} manifest prefix → spec names.
* @returns {{ mode: 'full' | 'selected' | 'none', specs: string[], reason: string,
* unmapped: string[] }}
* `specs` are spec NAMES (no path, no .e2e.spec.ts). `unmapped` lists src/
* files with no manifest prefix — logged in CI, never a failure (e2e is a
* thin layer over int; the gap is for a human to judge).
*/
export function selectE2eSpecs(files, manifest) {
if (files.some(({ path }) => isHighRisk(path))) {
return {
mode: 'full',
specs: [],
reason: 'diff touches a high-risk path (schema/lockfile/test harness)',
unmapped: [],
}
}
const specs = new Set()
const unmapped = []
for (const { path } of files) {
if (E2E_SPEC_PATTERN.test(path)) {
specs.add(path.slice('tests/e2e/'.length, -'.e2e.spec.ts'.length))
continue
}
if (!path.startsWith('src/')) continue
const matches = manifest.filter((entry) =>
entry.prefixes.some((prefix) => path.startsWith(prefix)),
)
if (matches.length === 0) {
unmapped.push(path)
continue
}
for (const match of matches) for (const spec of match.specs) specs.add(spec)
}
if (specs.size === 0) {
return {
mode: 'none',
specs: [],
reason:
unmapped.length > 0
? 'src/ changes with no e2e manifest mapping (see unmapped list)'
: 'no e2e-relevant changes',
unmapped,
}
}
return {
mode: 'selected',
specs: [...specs].sort(),
reason: `${specs.size} spec(s) selected via manifest and changed specs`,
unmapped,
}
}

View file

@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* run-e2e-affected — local mirror of the CI (PR) e2e job:
* 1. `e2e-affected.mjs` picks full vs manifest-selected vs skip (same base ref).
* 2. migrate + db:seed:minimal when anything would run.
* 3. optional production build into `.next/e2e` when `E2E_PROD=1`.
* 4. `pnpm test:e2e` (set `CI=1` and `E2E_PROD=1` together for prod mode).
*
* Extra Playwright args after `--` are forwarded (e.g. a single spec while debugging).
*
* GITHUB_BASE_REF=stage node scripts/run-e2e-affected.mjs
* pnpm test:e2e:affected -- tests/e2e/campaignHomeActions.e2e.spec.ts
*/
import { execFileSync, spawnSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const passthroughIndex = process.argv.indexOf('--')
const passthroughArgs = passthroughIndex === -1 ? [] : process.argv.slice(passthroughIndex + 1)
const scopeRaw = execFileSync('node', ['scripts/e2e-affected.mjs'], {
cwd: repoRoot,
encoding: 'utf8',
env: process.env,
})
const scope = JSON.parse(scopeRaw.trim())
console.log(`[e2e:affected] ${scope.reason} (base ${scope.base})`)
if (scope.mode === 'none') {
console.log('[e2e:affected] Nothing to run for this diff.')
process.exit(0)
}
const run = (command, args, env = {}) => {
const result = spawnSync(command, args, {
cwd: repoRoot,
stdio: 'inherit',
env: { ...process.env, ...env },
})
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
run('pnpm', ['migrate'])
run('pnpm', ['db:seed:minimal'])
if (process.env.E2E_PROD === '1') {
run('pnpm', ['build'], { NEXT_DIST_DIR: '.next/e2e' })
}
const playwrightArgs = ['test:e2e']
if (scope.mode === 'selected' && scope.specs.length > 0) {
for (const name of scope.specs) {
playwrightArgs.push(`tests/e2e/${name}.e2e.spec.ts`)
}
}
playwrightArgs.push(...passthroughArgs)
run('pnpm', playwrightArgs, {
...(process.env.E2E_PROD === '1' ? { CI: '1', E2E_PROD: '1' } : {}),
})

39
scripts/test-affected.mjs Normal file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env node
/**
* test-affected (OPS5) — decides the vitest scope for a PR diff and prints it
* as JSON on stdout: { mode, reason, base }.
*
* mode=full → run the whole suite (high-risk path / new src file)
* mode=changed → run `vitest --changed <base>`
* mode=none → nothing to run (docs-only diff); CI skips the step
*
* Base ref: $GITHUB_BASE_REF (PR target) or origin/stage locally. No DB, no
* Payload — pure git + the pure core in scripts/lib/test-affected-core.mjs.
*/
import { execFileSync } from 'node:child_process'
import { classifyTestScope } from './lib/test-affected-core.mjs'
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'stage'}`
let mergeBase
try {
mergeBase = execFileSync('git', ['merge-base', 'HEAD', base], { encoding: 'utf8' }).trim()
} catch {
const result = { mode: 'full', reason: `no merge-base with ${base} (shallow clone?)`, base }
console.log(JSON.stringify(result))
process.exit(0)
}
const raw = execFileSync('git', ['diff', '--name-status', `${mergeBase}...HEAD`], {
encoding: 'utf8',
})
const files = raw
.split('\n')
.filter(Boolean)
.map((line) => {
const [status, path] = line.split('\t')
return { status, path }
})
console.log(JSON.stringify({ ...classifyTestScope(files), base }))

View file

@ -77,8 +77,12 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
>
<body className="antialiased w-screen scrollbar-hide overflow-hidden">
<ThemeProvider attribute="class">
<SpeedInsights />
<Analytics />
{process.env.VERCEL === '1' ? (
<>
<SpeedInsights />
<Analytics />
</>
) : null}
<RefreshRouteOnSave />
{children}
</ThemeProvider>

View file

@ -4,10 +4,9 @@ import type { LucideIcon } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useId, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerClose,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
@ -149,11 +148,7 @@ export const CampaignHomeActionButton = ({
<DrawerDescription id={descriptionId}>{description}</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Fechar
</DrawerClose>
<DrawerCloseButton>Fechar</DrawerCloseButton>
</DrawerFooter>
</DrawerContent>
</Drawer>

View file

@ -1,10 +1,9 @@
import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
import { SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import {
advisorEntriesFromIds,
formatAdvisorNamesTooltip,
MissingAdvisorBadge,
MunicipalityAdvisorAvatarStack,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
@ -12,16 +11,19 @@ import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipal
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import { MunicipalityListMobileSection } from '@/components/campaign/municipality/MunicipalityListMobileSection'
import {
SignalAgeReadout,
TerritorialClassReadout,
} from '@/components/campaign/municipality/MunicipalityListRowReadouts'
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
import { MunicipalitySortableHead } from '@/components/campaign/municipality/MunicipalitySortableHead'
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import { Badge } from '@/components/ui/Badge'
import { buttonVariants } from '@/components/ui/button'
import {
Empty,
@ -41,9 +43,6 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro
import {
formatTerritorialClassWhy,
municipalityColumnDescriptions,
municipalityGeographyParts,
territorialClassBadgeVariant,
territorialClassLabels,
type MunicipalityListColumnId,
} from '@/utilities/municipality/municipalityLabels'
import {
@ -57,12 +56,6 @@ import {
resolveMunicipalityListSort,
type MunicipalityListState,
} from '@/utilities/municipality/municipalityListUrl'
import {
formatMunicipalitySignalAgeLabel,
isMunicipalitySignalCold,
MUNICIPALITY_COLD_SIGNAL_DAYS,
municipalitySignalAgeInDays,
} from '@/utilities/municipality/municipalitySignal'
import type {
EligibleAdvisorOption,
MunicipalityAdvisorSummary,
@ -102,87 +95,6 @@ export type MunicipalityListProps = {
columnVisibility: CampaignColumnVisibility
}
/**
* E10 classe. Only the pill is visible — spelling the factors out under it
* bloats the dense row on desktop and, on the mobile card, was the tallest
* block in a card the field asked us to shorten (B42). So the "por quê" is
* `sr-only` text here, and both surfaces add a tap/hover/focus channel around
* it: the column's `cellTooltip` in the table, an explicit `CampaignHoverTooltip`
* on the card. Neither may be dropped — the class must never reach anyone,
* sighted or not, as a bare verdict.
*/
const TerritorialClassReadout = ({ municipality }: { municipality: MunicipalityListViewModel }) => {
const why = formatTerritorialClassWhy(municipality.territorialClassFactors)
if (municipality.territorialClass === 'sem_base') {
// Same idiom as the "2022" column: absent data is a dash, not a pill.
return (
<span className="text-muted-foreground">
<span aria-hidden="true">—</span>
<span className="sr-only">{why}</span>
</span>
)
}
return (
<>
<Badge variant={territorialClassBadgeVariant[municipality.territorialClass]}>
{territorialClassLabels[municipality.territorialClass]}
</Badge>
<span className="sr-only">{why}</span>
</>
)
}
/**
* E9 frescor: how long since anybody recorded anything here (staff update or
* leadership pledge).
*
* Cold reads in the warning amber, NOT destructive: early in the campaign
* most municípios are past the 21-day threshold, and painting them all red
* would drown the one state that really is an error in this list — a priority
* município with nobody answering for it. Same reason the priority flag stays
* an icon instead of a third badge: the row already carries the priority signal and,
* when it applies, the missing-advisor one.
*/
const SignalAgeReadout = ({
lastSignalAt,
layout,
}: {
lastSignalAt: string | null
layout: 'table' | 'card'
}) => {
const ageInDays = municipalitySignalAgeInDays(lastSignalAt)
const isCold = isMunicipalitySignalCold(ageInDays)
const ageLabel = formatMunicipalitySignalAgeLabel(ageInDays)
const dateLabel = lastSignalAt ? dateFormatter.format(new Date(lastSignalAt)) : null
return (
<div
data-signal={isCold ? 'cold' : 'fresh'}
className={cn('flex flex-col gap-0.5', layout === 'card' && 'text-sm')}
title={
isCold
? `Sem registro novo há ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias ou mais — atualização da equipe ou declaração de liderança.`
: undefined
}
>
<span
className={cn(
'inline-flex items-center gap-1 tabular-nums',
isCold ? 'font-medium text-estimate-pending-foreground' : 'text-foreground',
)}
>
{isCold ? <CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" /> : null}
{ageLabel}
</span>
{dateLabel ? (
<span className="text-xs text-muted-foreground tabular-nums">{dateLabel}</span>
) : null}
</div>
)
}
/** Replaces only the rows: the filter header row and the overview stay put. */
const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState }) => (
<Empty className="min-h-56">
@ -214,11 +126,6 @@ const advisorEntries = (
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
) => advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById)
const advisorNames = (
municipality: MunicipalityListViewModel,
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
): string[] => advisorEntries(municipality, advisorNamesById).map((advisor) => advisor.name)
/**
* Desktop table columns (Pass 2 W1). `id` is the picker key, cookie key, and B22
* description key — typed instead of widening to `string`.
@ -524,172 +431,16 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
{sortSummary}
</p>
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
{municipalities.length === 0 ? <MunicipalityListEmptyState state={state} /> : null}
{municipalities.map((municipality) => {
const names = advisorNames(municipality, advisorNamesById)
const position = municipality.votePosition2022
const isPriority = municipality.priority === 'alta'
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
return (
<article
key={municipality.id}
className="relative flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
{/*
* The name's link stretches over the whole card, so tapping
* anywhere that isn't a control opens the município — B42
* removed the "Abrir município" button that used to be the
* only way in. Each control carries its own `relative`,
* which paints it above this overlay and keeps its own tap;
* nothing else in the card may be positioned, or it would
* become a hole in the card's tap target.
*/}
<h3 className="font-medium">
<Link
href={`/campanha/municipios/${municipality.slug}`}
className="rounded-md after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{municipality.name}
</Link>
</h3>
<p className="text-sm text-muted-foreground">
<span className="relative">
<TerritoryLink region={region} />
</span>
{zoneSuffix ? ` ${zoneSuffix}` : null}
</p>
{position ? (
<MunicipalityVotePositionReadout position={position} layout="card" />
) : null}
</div>
{isPriority && isStaffView ? (
<MunicipalityPriorityIndicator className="relative size-11" />
) : null}
</div>
{isStaffView ? (
<dl className="grid grid-cols-2 gap-2 text-sm">
<div>
<dt className="text-muted-foreground">Classe</dt>
<dd className="flex flex-wrap items-center gap-2">
{/*
* The card has no hover, so the "por quê" the class must
* never travel without gets the same tap-to-open channel
* the desktop column gets through `cellTooltip` — the
* `sr-only` copy inside the readout covers AT either way.
*/}
<CampaignHoverTooltip
content={
municipality.territorialClass === 'sem_base'
? null
: formatTerritorialClassWhy(municipality.territorialClassFactors)
}
align="start"
>
<span className="relative inline-flex items-center">
<TerritorialClassReadout municipality={municipality} />
</span>
</CampaignHoverTooltip>
</dd>
</div>
<div>
<dt className="text-muted-foreground">
{municipalityColumnLabels.goalCoverage}
</dt>
<dd>
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Votos estimados</dt>
<dd>
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="sheet"
/>
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Nível</dt>
<dd>
{props.canMoveEngagementLevel ? (
<MunicipalityListLevelControl
municipalityID={municipality.id}
municipalityName={municipality.name}
level={municipality.engagementLevel}
levelNote={municipality.levelNote}
levelChangedAt={municipality.levelChangedAt}
variant="sheet"
/>
) : (
<MunicipalityLevelBadge
level={municipality.engagementLevel}
note={municipality.levelNote}
layout="card"
/>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Tendência</dt>
<dd>
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalityName={municipality.name}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
variant="sheet"
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Último sinal</dt>
<dd>
<MunicipalityListSignalControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="sheet"
formAction={props.signalFormAction}
>
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
</MunicipalityListSignalControl>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Assessores</dt>
<dd>
{isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={isPriority}
advisorNamesById={advisorNamesById}
options={advisorOptions}
variant="sheet"
/>
) : names.length ? (
names.join(', ')
) : (
<MissingAdvisorBadge isPriority={isPriority} />
)}
</dd>
</div>
</dl>
) : null}
</article>
)
})}
</div>
<MunicipalityListMobileSection
municipalities={municipalities}
advisorNamesById={advisorNamesById}
isStaffView={isStaffView}
isCoordinator={isCoordinator}
canMoveEngagementLevel={props.canMoveEngagementLevel}
advisorOptions={advisorOptions}
signalFormAction={props.signalFormAction}
emptySlot={<MunicipalityListEmptyState state={state} />}
/>
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
<CampaignTable

View file

@ -12,7 +12,7 @@ import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useC
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { DrawerClose } from '@/components/ui/Drawer'
import { DrawerCloseButton } from '@/components/ui/Drawer'
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
@ -236,14 +236,10 @@ export const MunicipalityListLevelControl = ({
triggerBusy={isPending}
statusMessage={isPending ? 'Registrando nível.' : (errorMessage ?? '')}
footer={
isSheet ? (
isSheet && open ? (
<>
{submitButton}
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Cancelar
</DrawerClose>
<DrawerCloseButton>Cancelar</DrawerCloseButton>
</>
) : undefined
}

View file

@ -0,0 +1,204 @@
'use client'
import Link from 'next/link'
import type { ReactNode } from 'react'
import {
advisorEntriesFromIds,
MissingAdvisorBadge,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import {
SignalAgeReadout,
TerritorialClassCardReadout,
} from '@/components/campaign/municipality/MunicipalityListRowReadouts'
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { municipalityGeographyParts } from '@/utilities/municipality/municipalityLabels'
import { municipalityColumnLabels } from '@/utilities/municipality/municipalityListUrl'
import type {
EligibleAdvisorOption,
MunicipalityAdvisorSummary,
MunicipalityListViewModel,
} from '@/utilities/municipality/municipalityViewModels'
import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
type MunicipalityStaffFormAction = (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
export type MunicipalityListMobileCardsProps = {
municipalities: MunicipalityListViewModel[]
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
isStaffView: boolean
isCoordinator: boolean
canMoveEngagementLevel: boolean
advisorOptions: EligibleAdvisorOption[]
signalFormAction: MunicipalityStaffFormAction
emptySlot: ReactNode
}
/**
* B42 mobile cards with sheet-variant quick edits. Wrapped by
* `MunicipalityListMobileSection`, which hosts a single shared Drawer.
*/
export const MunicipalityListMobileCards = ({
municipalities,
advisorNamesById,
isStaffView,
isCoordinator,
canMoveEngagementLevel,
advisorOptions,
signalFormAction,
emptySlot,
}: MunicipalityListMobileCardsProps) => (
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
{municipalities.length === 0 ? emptySlot : null}
{municipalities.map((municipality) => {
const names = advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById).map(
(advisor) => advisor.name,
)
const position = municipality.votePosition2022
const isPriority = municipality.priority === 'alta'
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
return (
<article
key={municipality.id}
className="relative flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<h3 className="font-medium">
<Link
href={`/campanha/municipios/${municipality.slug}`}
className="rounded-md after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{municipality.name}
</Link>
</h3>
<p className="text-sm text-muted-foreground">
<span className="relative">
<TerritoryLink region={region} />
</span>
{zoneSuffix ? ` ${zoneSuffix}` : null}
</p>
{position ? (
<MunicipalityVotePositionReadout position={position} layout="card" />
) : null}
</div>
{isPriority && isStaffView ? (
<MunicipalityPriorityIndicator className="relative size-11" />
) : null}
</div>
{isStaffView ? (
<dl className="grid grid-cols-2 gap-2 text-sm">
<div>
<dt className="text-muted-foreground">Classe</dt>
<dd className="flex flex-wrap items-center gap-2">
<TerritorialClassCardReadout municipality={municipality} />
</dd>
</div>
<div>
<dt className="text-muted-foreground">{municipalityColumnLabels.goalCoverage}</dt>
<dd>
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Votos estimados</dt>
<dd>
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="sheet"
/>
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Nível</dt>
<dd>
{canMoveEngagementLevel ? (
<MunicipalityListLevelControl
municipalityID={municipality.id}
municipalityName={municipality.name}
level={municipality.engagementLevel}
levelNote={municipality.levelNote}
levelChangedAt={municipality.levelChangedAt}
variant="sheet"
/>
) : (
<MunicipalityLevelBadge
level={municipality.engagementLevel}
note={municipality.levelNote}
layout="card"
/>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Tendência</dt>
<dd>
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalityName={municipality.name}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
variant="sheet"
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Último sinal</dt>
<dd>
<MunicipalityListSignalControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="sheet"
formAction={signalFormAction}
>
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
</MunicipalityListSignalControl>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Assessores</dt>
<dd>
{isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={isPriority}
advisorNamesById={advisorNamesById}
options={advisorOptions}
variant="sheet"
/>
) : names.length ? (
names.join(', ')
) : (
<MissingAdvisorBadge isPriority={isPriority} />
)}
</dd>
</div>
</dl>
) : null}
</article>
)
})}
</div>
)

View file

@ -0,0 +1,14 @@
'use client'
import type { MunicipalityListMobileCardsProps } from '@/components/campaign/municipality/MunicipalityListMobileCards'
import { MunicipalityListMobileCards } from '@/components/campaign/municipality/MunicipalityListMobileCards'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
type MunicipalityListMobileSectionProps = MunicipalityListMobileCardsProps
/** Mobile cards (`md:hidden`) with one shared bottom sheet for all quick edits. */
export const MunicipalityListMobileSection = (props: MunicipalityListMobileSectionProps) => (
<CampaignListSheetProvider>
<MunicipalityListMobileCards {...props} />
</CampaignListSheetProvider>
)

View file

@ -0,0 +1,106 @@
'use client'
import { CircleAlertIcon } from 'lucide-react'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Badge } from '@/components/ui/Badge'
import { cn } from '@/lib/utils'
import {
formatTerritorialClassWhy,
territorialClassBadgeVariant,
territorialClassLabels,
} from '@/utilities/municipality/municipalityLabels'
import {
formatMunicipalitySignalAgeLabel,
isMunicipalitySignalCold,
MUNICIPALITY_COLD_SIGNAL_DAYS,
municipalitySignalAgeInDays,
} from '@/utilities/municipality/municipalitySignal'
import type { MunicipalityListViewModel } from '@/utilities/municipality/municipalityViewModels'
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
/** E10 classe readout — shared by the table column and the mobile card (B42). */
export const TerritorialClassReadout = ({
municipality,
}: {
municipality: MunicipalityListViewModel
}) => {
const why = formatTerritorialClassWhy(municipality.territorialClassFactors)
if (municipality.territorialClass === 'sem_base') {
return (
<span className="text-muted-foreground">
<span aria-hidden="true">—</span>
<span className="sr-only">{why}</span>
</span>
)
}
return (
<>
<Badge variant={territorialClassBadgeVariant[municipality.territorialClass]}>
{territorialClassLabels[municipality.territorialClass]}
</Badge>
<span className="sr-only">{why}</span>
</>
)
}
/** E9 frescor readout — shared by the table column and the mobile card. */
export const SignalAgeReadout = ({
lastSignalAt,
layout,
}: {
lastSignalAt: string | null
layout: 'table' | 'card'
}) => {
const ageInDays = municipalitySignalAgeInDays(lastSignalAt)
const isCold = isMunicipalitySignalCold(ageInDays)
const ageLabel = formatMunicipalitySignalAgeLabel(ageInDays)
const dateLabel = lastSignalAt ? dateFormatter.format(new Date(lastSignalAt)) : null
return (
<div
data-signal={isCold ? 'cold' : 'fresh'}
className={cn('flex flex-col gap-0.5', layout === 'card' && 'text-sm')}
title={
isCold
? `Sem registro novo há ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias ou mais — atualização da equipe ou declaração de liderança.`
: undefined
}
>
<span
className={cn(
'inline-flex items-center gap-1 tabular-nums',
isCold ? 'font-medium text-estimate-pending-foreground' : 'text-foreground',
)}
>
{isCold ? <CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" /> : null}
{ageLabel}
</span>
{dateLabel ? (
<span className="text-xs text-muted-foreground tabular-nums">{dateLabel}</span>
) : null}
</div>
)
}
export const TerritorialClassCardReadout = ({
municipality,
}: {
municipality: MunicipalityListViewModel
}) => (
<CampaignHoverTooltip
content={
municipality.territorialClass === 'sem_base'
? null
: formatTerritorialClassWhy(municipality.territorialClassFactors)
}
align="start"
>
<span className="relative inline-flex items-center">
<TerritorialClassReadout municipality={municipality} />
</span>
</CampaignHoverTooltip>
)

View file

@ -10,7 +10,7 @@ import {
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { Button } from '@/components/ui/button'
import { DrawerClose } from '@/components/ui/Drawer'
import { DrawerCloseButton } from '@/components/ui/Drawer'
import { Spinner } from '@/components/ui/Spinner'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
@ -82,14 +82,10 @@ export const MunicipalityListSignalControl = ({
contentClassName="w-80"
trigger={children}
footer={
isSheet ? (
isSheet && open ? (
<>
{submitButton}
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Cancelar
</DrawerClose>
<DrawerCloseButton>Cancelar</DrawerCloseButton>
</>
) : undefined
}

View file

@ -9,7 +9,7 @@ import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizard
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerClose,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
@ -104,9 +104,7 @@ export const WizardSignalTypeStep = ({
<p className="text-sm text-muted-foreground">{infoEntry.infoContent}</p>
</div>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" className="min-h-11 w-full" />}>
Fechar
</DrawerClose>
<DrawerCloseButton>Fechar</DrawerCloseButton>
</DrawerFooter>
</>
) : null}

View file

@ -1,12 +1,13 @@
'use client'
import { useRef, useState, type ReactNode } from 'react'
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Button } from '@/components/ui/button'
import { useCampaignListSheet } from '@/components/campaign/shared/CampaignListSheetHost'
import {
Drawer,
DrawerClose,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
@ -82,6 +83,10 @@ type CampaignCellEditOverlayProps = {
* Only the container lives here. Each control keeps its own auto-save scaffold
* (debounce, abort, flush on close), which is what `onOpenChange` carries — so
* closing the Drawer commits exactly like dismissing the Popover does.
*
* When a `CampaignListSheetProvider` wraps the list (B42 mobile cards), sheet
* cells publish into that single Drawer instead of mounting one per row — prod
* was tripping React #130 with ~125 idle drawer roots on a 25-row page.
*/
export const CampaignCellEditOverlay = ({
variant,
@ -102,6 +107,9 @@ export const CampaignCellEditOverlay = ({
preventPopoverAutoFocus,
children,
}: CampaignCellEditOverlayProps) => {
const sharedSheet = useCampaignListSheet()
const wasOpenRef = useRef(open)
/**
* The region is mounted from the first open onwards and never unmounted
* again — a live region has to exist BEFORE its text changes or most screen
@ -152,50 +160,73 @@ export const CampaignCellEditOverlay = ({
</p>
)
useLayoutEffect(() => {
if (variant !== 'sheet' || !sharedSheet) return
const wasOpen = wasOpenRef.current
wasOpenRef.current = open
if (!open) {
if (wasOpen) sharedSheet.dismissSheet(onOpenChange)
return
}
sharedSheet.openSheet({
title,
description,
hasCustomFooter: Boolean(footer),
sheetBodyClassName,
onOpenChange,
})
}, [variant, sharedSheet, open, title, description, footer, sheetBodyClassName, onOpenChange])
if (variant === 'sheet' && sharedSheet) {
const showPortals = open && sharedSheet.isActiveSheet(onOpenChange)
const bodyTarget = showPortals ? sharedSheet.bodyPortalRef.current : null
const footerTarget = showPortals ? sharedSheet.footerPortalRef.current : null
// portalRevision keeps this render in sync after the drawer mounts its targets.
void sharedSheet.portalRevision
return (
<>
{triggerButton}
{liveRegion}
{showPortals && bodyTarget ? createPortal(children, bodyTarget) : null}
{showPortals && footerTarget && footer ? createPortal(footer, footerTarget) : null}
</>
)
}
if (variant === 'sheet') {
return (
<>
{triggerButton}
{liveRegion}
<Drawer open={open} onOpenChange={onOpenChange}>
{/*
* Focus lands on the title, never on the first field: base-ui's
* default (first tabbable element) raises the virtual keyboard over
* the sheet before it can be read, and every one of these sheets is
* opened to look at a value at least as often as to type one. The
* title is focusable only programmatically (`tabIndex={-1}`), so it
* adds no stop to the tab order.
*/}
<DrawerContent initialFocus={titleRef}>
<DrawerHeader>
<DrawerTitle ref={titleRef} tabIndex={-1} className="outline-none">
{title}
</DrawerTitle>
{/* Guarded, not required: every sheet today names its row, but one
opened from under a heading that already does would pass none. */}
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<div
className={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-3 pb-2',
sheetBodyClassName,
)}
>
{children}
</div>
<DrawerFooter>
{footer ?? (
// Swiping down is the gesture, but it is not an affordance
// anyone sees.
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
{open || everOpened ? (
<Drawer open={open} onOpenChange={onOpenChange}>
{open ? (
<DrawerContent initialFocus={titleRef}>
<DrawerHeader>
<DrawerTitle ref={titleRef} tabIndex={-1} className="outline-none">
{title}
</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<div
className={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-3 pb-2',
sheetBodyClassName,
)}
>
Fechar
</DrawerClose>
)}
</DrawerFooter>
</DrawerContent>
</Drawer>
{children}
</div>
<DrawerFooter>
{footer ?? <DrawerCloseButton>Fechar</DrawerCloseButton>}
</DrawerFooter>
</DrawerContent>
) : null}
</Drawer>
) : null}
</>
)
}

View file

@ -0,0 +1,163 @@
'use client'
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
type RefObject,
} from 'react'
import {
Drawer,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import { cn } from '@/lib/utils'
export type CampaignListSheetChrome = {
title: string
description?: string
hasCustomFooter: boolean
sheetBodyClassName?: string
onOpenChange: (open: boolean) => void
}
type CampaignListSheetContextValue = {
bodyPortalRef: RefObject<HTMLDivElement | null>
footerPortalRef: RefObject<HTMLDivElement | null>
/** Bumps when the drawer body/footer mount targets attach — portals read this. */
portalRevision: number
openSheet: (chrome: CampaignListSheetChrome) => void
dismissSheet: (onOpenChange: (open: boolean) => void) => void
isActiveSheet: (onOpenChange: (open: boolean) => void) => boolean
}
const CampaignListSheetContext = createContext<CampaignListSheetContextValue | null>(null)
export const useCampaignListSheet = () => useContext(CampaignListSheetContext)
/**
* One bottom Drawer for an entire list surface (B42 mobile cards). Cell bodies
* portal into it so we never mount hundreds of Drawer roots and never sync form
* trees through provider state (which tripped React #185 in prod).
*/
export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) => {
const titleRef = useRef<HTMLHeadingElement | null>(null)
const bodyPortalRef = useRef<HTMLDivElement | null>(null)
const footerPortalRef = useRef<HTMLDivElement | null>(null)
const [open, setOpen] = useState(false)
const [chrome, setChrome] = useState<CampaignListSheetChrome | null>(null)
const [portalRevision, setPortalRevision] = useState(0)
const activeOnOpenChangeRef = useRef<((open: boolean) => void) | null>(null)
const attachBodyPortal = useCallback((node: HTMLDivElement | null) => {
bodyPortalRef.current = node
if (node) setPortalRevision((value) => value + 1)
}, [])
const attachFooterPortal = useCallback((node: HTMLDivElement | null) => {
footerPortalRef.current = node
if (node) setPortalRevision((value) => value + 1)
}, [])
const dismissSheet = useCallback((onOpenChange: (open: boolean) => void) => {
if (activeOnOpenChangeRef.current !== onOpenChange) return
setOpen(false)
activeOnOpenChangeRef.current = null
setChrome(null)
onOpenChange(false)
}, [])
const openSheet = useCallback((next: CampaignListSheetChrome) => {
const previousOnOpenChange = activeOnOpenChangeRef.current
if (previousOnOpenChange && previousOnOpenChange !== next.onOpenChange) {
previousOnOpenChange(false)
}
activeOnOpenChangeRef.current = next.onOpenChange
setChrome((current) => {
if (
current &&
current.onOpenChange === next.onOpenChange &&
current.title === next.title &&
current.description === next.description &&
current.sheetBodyClassName === next.sheetBodyClassName &&
current.hasCustomFooter === next.hasCustomFooter
) {
return current
}
return next
})
setOpen(true)
}, [])
const isActiveSheet = useCallback(
(onOpenChange: (open: boolean) => void) =>
open && activeOnOpenChangeRef.current === onOpenChange,
[open],
)
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) return
const onOpenChange = activeOnOpenChangeRef.current
if (!onOpenChange) {
setOpen(false)
setChrome(null)
return
}
dismissSheet(onOpenChange)
},
[dismissSheet],
)
const contextValue = useMemo(
() => ({
bodyPortalRef,
footerPortalRef,
portalRevision,
openSheet,
dismissSheet,
isActiveSheet,
}),
[portalRevision, openSheet, dismissSheet, isActiveSheet],
)
return (
<CampaignListSheetContext.Provider value={contextValue}>
{children}
<Drawer open={open} onOpenChange={handleOpenChange}>
{chrome && open ? (
<DrawerContent initialFocus={titleRef}>
<DrawerHeader>
<DrawerTitle ref={titleRef} tabIndex={-1} className="outline-none">
{chrome.title}
</DrawerTitle>
{chrome.description ? (
<DrawerDescription>{chrome.description}</DrawerDescription>
) : null}
</DrawerHeader>
<div
ref={attachBodyPortal}
className={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-3 pb-2',
chrome.sheetBodyClassName,
)}
/>
<DrawerFooter>
<div ref={attachFooterPortal} className="flex w-full flex-col gap-2" />
{!chrome.hasCustomFooter ? <DrawerCloseButton>Fechar</DrawerCloseButton> : null}
</DrawerFooter>
</DrawerContent>
) : null}
</Drawer>
</CampaignListSheetContext.Provider>
)
}

View file

@ -3,10 +3,9 @@
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerClose,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
@ -122,7 +121,7 @@ export const InstallPwaToast = () => {
}
}, [])
return (
return iosDrawerOpen ? (
<Drawer open={iosDrawerOpen} onOpenChange={setIosDrawerOpen}>
<DrawerContent>
<DrawerHeader>
@ -137,20 +136,9 @@ export const InstallPwaToast = () => {
<li>Confirme tocando em Adicionar.</li>
</ol>
<DrawerFooter>
<DrawerClose
render={
<Button
type="button"
variant="outline"
className="w-full"
onClick={dismissInstallToast}
/>
}
>
Entendi
</DrawerClose>
<DrawerCloseButton onClick={dismissInstallToast}>Entendi</DrawerCloseButton>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
) : null
}

View file

@ -1,8 +1,9 @@
'use client'
import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer'
import { Drawer as DrawerPrimitive } from '@base-ui/react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type DrawerContextProps = {
@ -62,6 +63,32 @@ const DrawerClose = ({ ...props }: DrawerPrimitive.Close.Props) => (
<DrawerPrimitive.Close data-slot="drawer-close" {...props} />
)
/** Styled close control — uses a render callback so prod bundles never hit base-ui's element `render` prop. */
const DrawerCloseButton = ({
className,
children,
onClick,
...props
}: React.ComponentProps<typeof Button>) => (
<DrawerClose
render={(closeProps) => (
<Button
type="button"
variant="outline"
className={cn('min-h-11 w-full', className)}
{...props}
{...closeProps}
onClick={(event) => {
closeProps.onClick?.(event)
onClick?.(event)
}}
/>
)}
>
{children}
</DrawerClose>
)
const DrawerOverlay = ({ className, ...props }: DrawerPrimitive.Backdrop.Props) => (
<DrawerPrimitive.Backdrop
data-slot="drawer-overlay"
@ -168,7 +195,7 @@ const DrawerDescription = ({ className, ...props }: DrawerPrimitive.Description.
export {
Drawer,
DrawerClose,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,

View file

@ -17,6 +17,12 @@ export type CampaignWebAuthnRelyingParty = {
const RP_NAME = 'Campanha Jorge Solla'
/** Matches `LOCAL_AUTHORITY_PATTERN` in `campaignInviteOrigin` — kept local on purpose. */
const LOCAL_REQUEST_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(?::[0-9]{1,5})?$/i
const isLocalRequestHost = (host: string | undefined): boolean =>
Boolean(host && LOCAL_REQUEST_HOST.test(host))
/**
* The authority the browser is actually talking to, which is what has to agree
* with the relying party. Compared as `host` (name + port) and not as a full
@ -61,9 +67,17 @@ const requestHostOf = ({
export const getCampaignWebAuthnRelyingParty = (
input: CampaignInviteOriginInput = {},
): CampaignWebAuthnRelyingParty | null => {
const requestHost = requestHostOf(input)
// `getCampaignInviteBaseURL` reads `process.env.NODE_ENV`, which Next inlines
// at build time as `"production"`. CI e2e serves that build on
// http://localhost:3000 (`pnpm start`), which would otherwise demand HTTPS
// DNS and hide biometrics. The request host is authoritative here.
const environment =
input.environment ?? (isLocalRequestHost(requestHost) ? ('test' as const) : undefined)
let baseURL: string
try {
baseURL = getCampaignInviteBaseURL(input)
baseURL = getCampaignInviteBaseURL(environment ? { ...input, environment } : input)
} catch {
return null
}
@ -74,7 +88,6 @@ export const getCampaignWebAuthnRelyingParty = (
// Absent host: a non-request context (a script, a test) that cannot be
// checked. The ceremonies always pass the headers.
const requestHost = requestHostOf(input)
if (requestHost && requestHost !== url.host.toLowerCase()) return null
return { rpID, rpName: RP_NAME, origin: url.origin }

View file

@ -43,15 +43,14 @@ test.describe('Início — busca global (B47)', () => {
})
await campaign.login(page, coordinator.email!, coordinator.password)
const actionsSlot = page.locator('[data-slot="home-actions"]')
await expect(actionsSlot).toBeVisible()
const actionsChrome = page.locator('[data-slot="home-actions-chrome"]')
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
await page.getByLabel('Buscar na campanha').focus()
await expect(actionsSlot).toBeHidden({ timeout: 5000 })
await expect(actionsChrome).toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
await page.getByLabel('Buscar na campanha').blur()
await expect(actionsSlot).toBeVisible({ timeout: 5000 })
await expect(actionsChrome).not.toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
})
test('staff typing in search keeps action strip hidden after debounce', async ({
@ -64,14 +63,15 @@ test.describe('Início — busca global (B47)', () => {
})
await campaign.login(page, coordinator.email!, coordinator.password)
const actionsSlot = page.locator('[data-slot="home-actions"]')
await expect(actionsSlot).toBeVisible()
const actionsChrome = page.locator('[data-slot="home-actions-chrome"]')
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
await page.getByLabel('Buscar na campanha').fill('ca')
await expect(actionsSlot).toBeHidden({ timeout: 5000 })
await expect(actionsChrome).toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
await page.getByLabel('Buscar na campanha').fill('')
await expect(actionsSlot).toBeVisible({ timeout: 5000 })
await page.getByLabel('Buscar na campanha').blur()
await expect(actionsChrome).not.toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
})
test('staff search shows municipality hits and opens detail (B48)', async ({
@ -148,6 +148,8 @@ test.describe('Início — catálogo de ações (B45)', () => {
})
test.describe('Wizard — busca município (B60)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('selecting a municipality advances with ?municipio= and sticky caption', async ({
campaign,
page,
@ -165,8 +167,8 @@ test.describe('Wizard — busca município (B60)', () => {
await search.fill('Cairu')
const results = page.getByRole('region', { name: 'Resultados da busca' })
await expect(results.getByRole('button', { name: /Cairu/i })).toBeVisible({ timeout: 15000 })
await results.getByRole('button', { name: /Cairu/i }).click()
await expect(results.getByRole('link', { name: /Cairu/i })).toBeVisible({ timeout: 15000 })
await results.getByRole('link', { name: /Cairu/i }).click()
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\?municipio=cairu/)
await expect(page.getByLabel(/Município em atualização: Cairu/i)).toBeVisible()
@ -299,12 +301,7 @@ test.describe('Wizard — registrar sinal (B63)', () => {
})
await expect(page.getByRole('link', { name: 'Pular registro de sinal' })).toHaveCount(0)
await page
.getByRole('listitem')
.filter({ hasText: 'Invasão' })
.getByRole('button')
.first()
.click()
await page.getByRole('link', { name: /Invasão/i }).click()
await page.waitForURL(/signalType=invasao/)
await expect(page.getByRole('heading', { name: /Detalhar sinal: Invasão/i })).toBeVisible()

View file

@ -55,11 +55,14 @@ test.describe('Filtros salvos de Municípios', () => {
await expect(shortcut).toHaveAttribute('aria-current', 'page')
// A page is a position inside the recorte, not part of it.
await page.goto(`${SAVED_RECORTE}&page=2`)
await page.goto(`${SAVED_RECORTE}&page=2`, NAVIGATION)
await expect(shortcut).toHaveAttribute('aria-current', 'page')
// Another recorte is not this one, and the shortcut brings it back.
await page.goto('/campanha/municipios')
// Another recorte is not this one, and the shortcut brings it back. Use the
// sidebar link — a bare `goto` here races an in-flight RSC navigation and
// flakes as net::ERR_ABORTED under prod load (measured in CI 2026-07-30).
await page.getByRole('link', { name: 'Municípios', exact: true }).click()
await expect(page).toHaveURL('/campanha/municipios', NAVIGATION)
await expect(shortcut).not.toHaveAttribute('aria-current', 'page')
await shortcut.click()
await expect(page).toHaveURL(SAVED_RECORTE, NAVIGATION)

View file

@ -63,6 +63,6 @@ test.describe('Territórios de Identidade', () => {
await expect(page.getByRole('link', { name: 'Territórios', exact: true })).toHaveCount(0)
await page.goto('/campanha/territorios')
await expect(page).toHaveURL(/\/campanha\/?$/)
await expect(page).toHaveURL(/\/campanha\/contatos/)
})
})

View file

@ -39,7 +39,7 @@ test.describe('Wizard — header mobile (B75)', () => {
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu')
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
timeout: 15000,
})

View file

@ -15,6 +15,7 @@ test('the dashboard map paints Salvador zone by zone and opens one', async ({ ca
const password = coordinator.password
await campaign.login(page, coordinator.email!, password)
await page.goto('/campanha/quadro')
await expect(page.getByRole('heading', { name: 'Mapa dos Municípios' })).toBeVisible({
timeout: 60_000,

View file

@ -46,6 +46,15 @@ describe('getCampaignWebAuthnRelyingParty', () => {
).toMatchObject({ rpID: 'localhost', origin: 'http://localhost:3100' })
})
it('follows localhost when a production build is served there (CI e2e)', () => {
expect(
getCampaignWebAuthnRelyingParty({
configuredURL: 'https://pt.jorgesolla.com.br',
forwardedHost: 'localhost:3000',
}),
).toMatchObject({ rpID: 'localhost', origin: 'http://localhost:3000' })
})
it('refuses production without a configured site URL', () => {
expect(
getCampaignWebAuthnRelyingParty({ environment: 'production', configuredURL: undefined }),

View file

@ -0,0 +1,45 @@
// @vitest-environment node
import { existsSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { E2E_AFFECTED_MANIFEST } from '../../scripts/lib/e2e-affected-manifest.mjs'
const E2E_DIR = join(__dirname, '../e2e')
function specNamesOnDisk() {
return readdirSync(E2E_DIR)
.filter((file) => file.endsWith('.e2e.spec.ts'))
.map((file) => file.slice(0, -'.e2e.spec.ts'.length))
}
describe('E2E_AFFECTED_MANIFEST (OPS5)', () => {
it('only references specs that exist on disk', () => {
const onDisk = new Set(specNamesOnDisk())
for (const entry of E2E_AFFECTED_MANIFEST) {
for (const spec of entry.specs) {
expect(onDisk.has(spec), `${spec} (prefixes: ${entry.prefixes.join(', ')})`).toBe(true)
expect(existsSync(join(E2E_DIR, `${spec}.e2e.spec.ts`))).toBe(true)
}
}
})
it('keeps prefixes repo-relative and trailing-slash-clean', () => {
for (const entry of E2E_AFFECTED_MANIFEST) {
expect(entry.prefixes.length).toBeGreaterThan(0)
for (const prefix of entry.prefixes) {
expect(prefix.startsWith('src/')).toBe(true)
expect(prefix.endsWith('/'), `${prefix} — drop the trailing slash`).toBe(false)
}
}
})
it('covers every on-disk spec family that has a clear domain prefix', () => {
// Smoke/infrastructure specs are allowed to be unmapped (they run in full
// mode); this pins the reverse direction: no manifest entry is dead.
const mapped = new Set(E2E_AFFECTED_MANIFEST.flatMap((entry) => entry.specs))
expect(mapped.size).toBeGreaterThanOrEqual(10)
})
})

View file

@ -0,0 +1,87 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
classifyTestScope,
HIGH_RISK_EXACT,
HIGH_RISK_PREFIXES,
selectE2eSpecs,
} from '../../scripts/lib/test-affected-core.mjs'
const changed = (path: string) => ({ path, status: 'M' })
describe('classifyTestScope (OPS5)', () => {
it('runs the full suite when a high-risk path changes', () => {
for (const path of [
'src/migrations/20260730_000000_x.ts',
'src/payload-types.ts',
'pnpm-lock.yaml',
'src/collections/Post.ts',
'scripts/lib/seed-minimal-manifest.mjs',
'tests/helpers/campaignFixtures.ts',
]) {
expect(classifyTestScope([changed(path)]).mode, path).toBe('full')
}
})
it('runs the full suite when a new src file appears', () => {
expect(classifyTestScope([{ path: 'src/utilities/newThing.ts', status: 'A' }]).mode).toBe(
'full',
)
})
it('selects vitest --changed for a plain src/tests diff', () => {
expect(classifyTestScope([changed('src/utilities/posts.ts')]).mode).toBe('changed')
expect(classifyTestScope([changed('tests/int/api.int.spec.ts')]).mode).toBe('changed')
})
it('skips tests for a docs-only diff', () => {
expect(classifyTestScope([changed('docs/AGENT-OPS.md'), changed('README.md')]).mode).toBe(
'none',
)
})
it('high-risk lists cover the harness files the CI relies on', () => {
expect(HIGH_RISK_EXACT.has('vitest.config.mts')).toBe(true)
expect(HIGH_RISK_EXACT.has('playwright.config.ts')).toBe(true)
expect(HIGH_RISK_PREFIXES).toContain('tests/e2e/fixtures/')
})
})
describe('selectE2eSpecs (OPS5)', () => {
const manifest = [
{ prefixes: ['src/components/campaign/municipality'], specs: ['campaignMunicipalities'] },
{ prefixes: ['src/app/(payload)'], specs: ['admin'] },
]
it('maps src prefixes to specs via the manifest', () => {
const result = selectE2eSpecs(
[changed('src/components/campaign/municipality/MunicipalityTable.tsx')],
manifest,
)
expect(result).toMatchObject({ mode: 'selected', specs: ['campaignMunicipalities'] })
})
it('runs a changed e2e spec directly', () => {
const result = selectE2eSpecs([changed('tests/e2e/campaignAuth.e2e.spec.ts')], manifest)
expect(result.mode).toBe('selected')
expect(result.specs).toContain('campaignAuth')
})
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')
})
it('reports unmapped src paths without failing and skips the run', () => {
const result = selectE2eSpecs([changed('src/utilities/brandNewModule.ts')], manifest)
expect(result.mode).toBe('none')
expect(result.unmapped).toEqual(['src/utilities/brandNewModule.ts'])
})
it('skips e2e for diffs with no src/e2e changes', () => {
const result = selectE2eSpecs([changed('docs/AGENT-OPS.md')], manifest)
expect(result).toMatchObject({ mode: 'none', specs: [], unmapped: [] })
})
})