diff --git a/.cursor/rules/engineering-standards.mdc b/.cursor/rules/engineering-standards.mdc index b4cc365f..677ed1be 100644 --- a/.cursor/rules/engineering-standards.mdc +++ b/.cursor/rules/engineering-standards.mdc @@ -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 ` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3). ## Type honesty diff --git a/.cursor/skills/work-issue/SKILL.md b/.cursor/skills/work-issue/SKILL.md index 43647710..1ff41e8c 100644 --- a/.cursor/skills/work-issue/SKILL.md +++ b/.cursor/skills/work-issue/SKILL.md @@ -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/-` (worktrees do Cursor são donos da criação; commits lógicos). 3. `gh pr create --base stage` com `Closes #` no body. 4. `gh pr merge --auto --merge `. -5. **Acompanhe os checks até o merge** (`gh pr checks --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 --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/.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 diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 239b750c..ff1aa302 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f99cc0f7..7ca22099 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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") + ' diff --git a/.husky/pre-push b/.husky/pre-push index 0e825e66..3461751c 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -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 diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 4adab044..04e25596 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -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) diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index ec05a818..6f98399e 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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`). diff --git a/docs/TESTING.md b/docs/TESTING.md index 24f80a1d..6114d009 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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`) diff --git a/docs/plans/ci-paralela-pr.md b/docs/plans/ci-paralela-pr.md new file mode 100644 index 00000000..73772d88 --- /dev/null +++ b/docs/plans/ci-paralela-pr.md @@ -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) diff --git a/docs/plans/desbloquear-pr50-ci.md b/docs/plans/desbloquear-pr50-ci.md new file mode 100644 index 00000000..50a043b9 --- /dev/null +++ b/docs/plans/desbloquear-pr50-ci.md @@ -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) diff --git a/docs/plans/gate-push-local.md b/docs/plans/gate-push-local.md new file mode 100644 index 00000000..e5fc4f18 --- /dev/null +++ b/docs/plans/gate-push-local.md @@ -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 diff --git a/docs/plans/politica-ci-dono-do-pr.md b/docs/plans/politica-ci-dono-do-pr.md new file mode 100644 index 00000000..300badcf --- /dev/null +++ b/docs/plans/politica-ci-dono-do-pr.md @@ -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 diff --git a/docs/plans/testes-afetados-pr.md b/docs/plans/testes-afetados-pr.md new file mode 100644 index 00000000..41a5e3af --- /dev/null +++ b/docs/plans/testes-afetados-pr.md @@ -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 diff --git a/package.json b/package.json index 7d53b7d7..757dd124 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index a9efe28d..91fe8301 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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. diff --git a/scripts/e2e-affected.mjs b/scripts/e2e-affected.mjs new file mode 100644 index 00000000..89829f89 --- /dev/null +++ b/scripts/e2e-affected.mjs @@ -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/.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)) diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs new file mode 100644 index 00000000..459b3ffe --- /dev/null +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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/.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'], + }, +] diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs new file mode 100644 index 00000000..294a77e9 --- /dev/null +++ b/scripts/lib/test-affected-core.mjs @@ -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 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, + } +} diff --git a/scripts/run-e2e-affected.mjs b/scripts/run-e2e-affected.mjs new file mode 100644 index 00000000..1df1c734 --- /dev/null +++ b/scripts/run-e2e-affected.mjs @@ -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' } : {}), +}) diff --git a/scripts/test-affected.mjs b/scripts/test-affected.mjs new file mode 100644 index 00000000..3221ea5f --- /dev/null +++ b/scripts/test-affected.mjs @@ -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 ` + * 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 })) diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index aede5250..c16c2d32 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -77,8 +77,12 @@ export default async function RootLayout(props: { children: React.ReactNode }) { > - - + {process.env.VERCEL === '1' ? ( + <> + + + + ) : null} {children} diff --git a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx index 0d682e9d..43505bfa 100644 --- a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx @@ -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 = ({ {description} - } - > - Fechar - + Fechar diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 0c5c6281..e53f2bc5 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -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 ( - - - {why} - - ) - } - - return ( - <> - - {territorialClassLabels[municipality.territorialClass]} - - {why} - - ) -} - -/** - * 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 ( -
- - {isCold ? - {dateLabel ? ( - {dateLabel} - ) : null} -
- ) -} - /** Replaces only the rows: the filter header row and the overview stay put. */ const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState }) => ( @@ -214,11 +126,6 @@ const advisorEntries = ( advisorNamesById: ReadonlyMap, ) => advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById) -const advisorNames = ( - municipality: MunicipalityListViewModel, - advisorNamesById: ReadonlyMap, -): 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}

-
- {municipalities.length === 0 ? : null} - {municipalities.map((municipality) => { - const names = advisorNames(municipality, advisorNamesById) - const position = municipality.votePosition2022 - const isPriority = municipality.priority === 'alta' - const { region, zoneSuffix } = municipalityGeographyParts(municipality) - return ( -
-
-
- {/* - * 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. - */} -

- - {municipality.name} - -

-

- - - - {zoneSuffix ? ` ${zoneSuffix}` : null} -

- {position ? ( - - ) : null} -
- {isPriority && isStaffView ? ( - - ) : null} -
- {isStaffView ? ( -
-
-
Classe
-
- {/* - * 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. - */} - - - - - -
-
-
-
- {municipalityColumnLabels.goalCoverage} -
-
- -
-
-
-
Votos estimados
-
- -
-
-
-
Nível
-
- {props.canMoveEngagementLevel ? ( - - ) : ( - - )} -
-
-
-
Tendência
-
- -
-
-
-
Último sinal
-
- - - -
-
-
-
Assessores
-
- {isCoordinator ? ( - - ) : names.length ? ( - names.join(', ') - ) : ( - - )} -
-
-
- ) : null} -
- ) - })} -
+ } + /> {/* A sticky can't paint the row border, hence the inset shadow. */} {submitButton} - } - > - Cancelar - + Cancelar ) : undefined } diff --git a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx new file mode 100644 index 00000000..36206150 --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx @@ -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 + +export type MunicipalityListMobileCardsProps = { + municipalities: MunicipalityListViewModel[] + advisorNamesById: ReadonlyMap + 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) => ( +
+ {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 ( +
+
+
+

+ + {municipality.name} + +

+

+ + + + {zoneSuffix ? ` ${zoneSuffix}` : null} +

+ {position ? ( + + ) : null} +
+ {isPriority && isStaffView ? ( + + ) : null} +
+ {isStaffView ? ( +
+
+
Classe
+
+ +
+
+
+
{municipalityColumnLabels.goalCoverage}
+
+ +
+
+
+
Votos estimados
+
+ +
+
+
+
Nível
+
+ {canMoveEngagementLevel ? ( + + ) : ( + + )} +
+
+
+
Tendência
+
+ +
+
+
+
Último sinal
+
+ + + +
+
+
+
Assessores
+
+ {isCoordinator ? ( + + ) : names.length ? ( + names.join(', ') + ) : ( + + )} +
+
+
+ ) : null} +
+ ) + })} +
+) diff --git a/src/components/campaign/municipality/MunicipalityListMobileSection.tsx b/src/components/campaign/municipality/MunicipalityListMobileSection.tsx new file mode 100644 index 00000000..f77db0cf --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityListMobileSection.tsx @@ -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) => ( + + + +) diff --git a/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx b/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx new file mode 100644 index 00000000..76cfa2da --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx @@ -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 ( + + + {why} + + ) + } + + return ( + <> + + {territorialClassLabels[municipality.territorialClass]} + + {why} + + ) +} + +/** 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 ( +
+ + {isCold ? + {dateLabel ? ( + {dateLabel} + ) : null} +
+ ) +} + +export const TerritorialClassCardReadout = ({ + municipality, +}: { + municipality: MunicipalityListViewModel +}) => ( + + + + + +) diff --git a/src/components/campaign/municipality/MunicipalityListSignalControl.tsx b/src/components/campaign/municipality/MunicipalityListSignalControl.tsx index 7960abfd..2cbe9adf 100644 --- a/src/components/campaign/municipality/MunicipalityListSignalControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListSignalControl.tsx @@ -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} - } - > - Cancelar - + Cancelar ) : undefined } diff --git a/src/components/campaign/municipality/WizardSignalTypeStep.tsx b/src/components/campaign/municipality/WizardSignalTypeStep.tsx index df61d9a4..a1272c29 100644 --- a/src/components/campaign/municipality/WizardSignalTypeStep.tsx +++ b/src/components/campaign/municipality/WizardSignalTypeStep.tsx @@ -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 = ({

{infoEntry.infoContent}

- }> - Fechar - + Fechar ) : null} diff --git a/src/components/campaign/shared/CampaignCellEditOverlay.tsx b/src/components/campaign/shared/CampaignCellEditOverlay.tsx index 32e0dff0..64ad6f50 100644 --- a/src/components/campaign/shared/CampaignCellEditOverlay.tsx +++ b/src/components/campaign/shared/CampaignCellEditOverlay.tsx @@ -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 = ({

) + 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} - - {/* - * 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. - */} - - - - {title} - - {/* Guarded, not required: every sheet today names its row, but one - opened from under a heading that already does would pass none. */} - {description ? {description} : null} - -
- {children} -
- - {footer ?? ( - // Swiping down is the gesture, but it is not an affordance - // anyone sees. - } + {open || everOpened ? ( + + {open ? ( + + + + {title} + + {description ? {description} : null} + +
- Fechar - - )} - - - + {children} +
+ + {footer ?? Fechar} + +
+ ) : null} +
+ ) : null} ) } diff --git a/src/components/campaign/shared/CampaignListSheetHost.tsx b/src/components/campaign/shared/CampaignListSheetHost.tsx new file mode 100644 index 00000000..2d882e7a --- /dev/null +++ b/src/components/campaign/shared/CampaignListSheetHost.tsx @@ -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 + footerPortalRef: RefObject + /** 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(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(null) + const bodyPortalRef = useRef(null) + const footerPortalRef = useRef(null) + const [open, setOpen] = useState(false) + const [chrome, setChrome] = useState(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 ( + + {children} + + {chrome && open ? ( + + + + {chrome.title} + + {chrome.description ? ( + {chrome.description} + ) : null} + +
+ +
+ {!chrome.hasCustomFooter ? Fechar : null} + + + ) : null} + + + ) +} diff --git a/src/components/campaign/shell/InstallPwaToast.tsx b/src/components/campaign/shell/InstallPwaToast.tsx index 13986937..c0944852 100644 --- a/src/components/campaign/shell/InstallPwaToast.tsx +++ b/src/components/campaign/shell/InstallPwaToast.tsx @@ -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 ? ( @@ -137,20 +136,9 @@ export const InstallPwaToast = () => {
  • Confirme tocando em Adicionar.
  • - - } - > - Entendi - + Entendi
    - ) + ) : null } diff --git a/src/components/ui/Drawer.tsx b/src/components/ui/Drawer.tsx index 4d6f0559..3fc9ce51 100644 --- a/src/components/ui/Drawer.tsx +++ b/src/components/ui/Drawer.tsx @@ -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) => ( ) +/** 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) => ( + ( +