mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #51 from fsolla/agent/ops-ci-workflow
chore(ci): parallel PR jobs, affected tests, push gate, owner-of-red-CI policy
This commit is contained in:
commit
621dee0440
42 changed files with 1982 additions and 455 deletions
|
|
@ -11,7 +11,7 @@ Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IM
|
|||
|
||||
1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive.
|
||||
2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment.
|
||||
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (antes de cada push): `pnpm lint`, `pnpm exec tsc --noEmit`, `pnpm test:unit` (+ `pnpm format:check` e `pnpm check:cycles` antes de commitar — o pre-push hook cobre os três primeiros). Se a entrega tocou schema: também `pnpm migrate && pnpm db:seed:minimal && pnpm test:int`. **Gate completo = CI do PR** (`ci-pr.yml`: os anteriores + knip + int + build, contra o DB mínimo). O handoff humano (promote `stage→main`) exige CI stage + ci-pr green — não o build local.
|
||||
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (antes de cada push): `pnpm gate:fast` (lint + typecheck + test:unit); o pre-push hook roda `pnpm gate:push` (fast + format:check + check:cycles). Fonte única: scripts `gate:*` em `package.json`; escape para WIP: `git push --no-verify`. Se a entrega tocou schema: também `pnpm migrate && pnpm db:seed:minimal && pnpm test:int`. **Gate completo = CI do PR** (`ci-pr.yml`: jobs paralelos — estático + unit/int afetados pelo diff + e2e por manifesto + build, contra o DB mínimo). O handoff humano (promote `stage→main`) exige CI stage + ci-pr green — não o build local. **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`).
|
||||
4. **Run gate commands bare, never piped** (`pnpm test | tail` swallows the exit code). Never run `knip --fix` blind — verify each removal with `git grep -w <symbol>` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3).
|
||||
|
||||
## Type honesty
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ Ordem fixa, fases pequenas e verificáveis, respeitando o appetite do plano:
|
|||
|
||||
1. **Schema e server** — migrations (`pnpm migrate:create`, seguir `payload-migrations`), collections, utilities, server actions, testes de domínio. Guardrails do repo: Local API com `user` → `overrideAccess: false`; escrita multi-collection → transação com `req: { transactionID }`; pessoa → join com `Contact`; opt-in/PII → `Consent` por chave estável falhando fechado.
|
||||
2. **UI via /impeccable** (classes B/C/D — a classe está no plano; se A, declare "Impeccable: N/A" e siga só engenharia): shape conforme a classe → craft → critique → (harden/optimize **só sob gatilho**) → polish. Paleta = tokens `data-theme='campaign'`; reusar `src/components/ui` e shells existentes; shape obrigatório em C **para** para confirmação do brief antes do craft.
|
||||
3. **Gates de engenharia** — fast gate local: `pnpm lint`, `pnpm exec tsc --noEmit`, `pnpm test:unit`, `pnpm format:check`, `pnpm check:cycles` (+ `pnpm migrate && pnpm db:seed:minimal && pnpm test:int` se tocou schema). Scan Aikido dos arquivos editados. Comandos bare, nunca piped.
|
||||
3. **Gates de engenharia** — fast gate local: `pnpm gate:fast` (lint + typecheck + test:unit) e, antes do push, `pnpm gate:push` (fast + format:check + check:cycles — o pre-push hook roda exatamente isso) (+ `pnpm migrate && pnpm db:seed:minimal && pnpm test:int` se tocou schema). Scan Aikido dos arquivos editados. Comandos bare, nunca piped.
|
||||
|
||||
Tracer bullet: se a Issue for grande, a primeira fatia vertical real (schema mínimo → uma action → uma superfície UI) vem cedo.
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ Rode `/simplify` sobre o diff da sessão. Follow-ups maiores que o cleanup → `
|
|||
2. Branch `agent/<id>-<slug>` (worktrees do Cursor são donos da criação; commits lógicos).
|
||||
3. `gh pr create --base stage` com `Closes #<N>` no body.
|
||||
4. `gh pr merge --auto --merge <PR>`.
|
||||
5. **Acompanhe os checks até o merge** (`gh pr checks <PR> --watch`): falha no ci-pr → corrige na mesma branch e reempurra (o auto-merge dispara de novo quando verde).
|
||||
5. **Acompanhe os checks até o merge** (`gh pr checks <PR> --watch`): falha no ci-pr → corrige na mesma branch e reempurra (o auto-merge dispara de novo quando verde). **Qualquer falha é tua** — infra do workflow, teste pré-existente ou regressão da feature; "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`; exceção de blast radius: migration/access/Consent → para e escala).
|
||||
6. O flip `in-progress → done` é **determinístico no CI** (workflow `issue-done-on-stage-merge.yml` lê o `Closes #N` no merge em stage). O agente espera o merge apenas para verificar e **consolidar pontas soltas**: débitos não registrados, doc da sessão pendente (notebook do projeto + `docs/plans/<slug>.md` com Status/Atualizado em), comentário de fechamento na Issue com o que entrou. Se o agente falhar depois do merge, o status já está correto.
|
||||
|
||||
## Resumo final ao usuário
|
||||
|
|
|
|||
299
.github/workflows/ci-pr.yml
vendored
299
.github/workflows/ci-pr.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
145
.github/workflows/ci.yml
vendored
145
.github/workflows/ci.yml
vendored
|
|
@ -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")
|
||||
'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
90
docs/plans/ci-paralela-pr.md
Normal file
90
docs/plans/ci-paralela-pr.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# CI paralela no PR — quebrar o job único do ci-pr em jobs com needs reais
|
||||
|
||||
Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano)
|
||||
Atualizado em: 2026-07-30
|
||||
Issue: —
|
||||
Priority: P1
|
||||
Model: composer-2.5
|
||||
Impeccable: A — N/A (sem superfície UI)
|
||||
Appetite: ~1 dia eng; 1 workflow reescrito + matriz documentada em AGENT-OPS
|
||||
Responsável: —
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
Dados: N/A — infra de CI. A métrica de sucesso é wall-clock do workflow (ver critérios de aceite), lida no próprio GitHub Actions.
|
||||
|
||||
## Contexto
|
||||
|
||||
`.github/workflows/ci-pr.yml` roda **tudo em série num único job `checks`**: lint → format → tsc → knip → madge → unit → migrate → seed → int → build (último run do PR #50: 9m37s até falhar no int — e a falha só aparece no minuto ~9, depois de todos os gates baratos passarem). Cada falha de lint custa um ciclo completo de feedback. `.github/workflows/ci.yml` (main) replica o mesmo job único sem `seed:minimal`, sem `build` e sem e2e; `ci-stage.yml` roda só `migrate` + smoke int contra Neon (subset curado — a suíte completa estourou 30 min duas vezes em 2026-07-30 por latência, comentário no próprio workflow).
|
||||
|
||||
Decisão de produto (2026-07-30, brief do lote CI): jobs paralelizáveis com `needs` só onde há dependência real; push em `stage` e `main` roda suíte **completa** como rede de segurança.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- `ci-pr.yml` vira N jobs paralelos; feedback de lint/format/typecheck em < ~3 min.
|
||||
- Dependências reais explícitas: só `int` e `build` precisam do Postgres service; `build` pode `needs: [int]` (ou rodar contra o próprio service — decidir na implementação por tempo medido).
|
||||
- Matriz **PR vs push stage vs push main** documentada em `docs/AGENT-OPS.md` (tabela "CI por alvo").
|
||||
- Invariantes preservados: `pnpm build` nunca perto de `STAGE_DATABASE_URL`; comandos bare; `migration-lock` intacto (com o fix de checkout do OPS3).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Stage continua smoke (migrate + subset curado), não suíte completa.** A suíte int completa contra Neon remoto estourou o timeout duas vezes (registrado no comentário de `ci-stage.yml:22-30`); a rede de segurança completa fica no ci-pr (DB mínimo) e no ci.yml (main). **Rejeitado:** full int no stage (latência ~120 ms/query — inviável, já medido); dropar o smoke do stage (é a única verificação contra snapshot real de prod).
|
||||
- **`ci.yml` (main) ganha `db:seed:minimal` + `build` + int completo, espelhando o ci-pr.** Hoje main não roda seed/build/e2e; como o promote PR já passou no ci-pr, o ci.yml é rede de segurança contra drift de merge — barato (service DB local). **Rejeitado:** manter main minimal (a tensão do brief fica sem resolução); build contra qualquer URL remota (proibido).
|
||||
- **E2E entra no ci-pr como job afetado por manifesto (OPS5), bloqueante; stage não roda e2e; main roda full e2e.** Detalhes e fallback no plano OPS5. **Rejeitado:** e2e fora do caminho crítico para sempre (o "fora do caminho crítico" do AGENT-OPS era placeholder "nightly/label futura" — este lote é a futura); full e2e em todo PR (custo de boot do dev server por PR sem relação com o diff).
|
||||
- **Uma estrutura de jobs compartilhada via YAML anchors/reusable steps, sem reusable-workflow separado.** **Rejeitado:** extrair `.github/workflows/_reusable.yml` (cerimônia para 2 consumidores; revisitado quando o 3º workflow precisar da mesma cadeia).
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **`build` precisa de `needs: [int]`?** **Opções:** A) build paralelo ao int (cada um com seu service Postgres) | B) build só após int verde. **Recomendação:** A — build não depende do resultado do int; paralelo corta o caminho crítico. Se medir contenção de runner, cair para B é mudança de uma linha.
|
||||
- **Cache de pnpm/Next entre jobs.** **Opções:** A) cada job instala (cache pnpm já existe) | B) artefato de `node_modules` compartilhado. **Recomendação:** A — upload/download de artefato costuma perder para o cache pnpm; medir no primeiro run.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph pr [ci-pr.yml]
|
||||
lint[lint] --- fmt[format] --- tsc[typecheck] --- knip --- madge[cycles] --- unit
|
||||
intjob["int (service PG: migrate+seed)"] --- buildjob["build (service PG)"]
|
||||
mlock[migration-lock]
|
||||
end
|
||||
subgraph main [ci.yml]
|
||||
full[gate completo + seed + build + e2e full]
|
||||
end
|
||||
subgraph stage [ci-stage.yml]
|
||||
smoke[migrate + smoke int subset]
|
||||
end
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`.github/workflows/ci-pr.yml`** — quebrar `checks` em jobs: `lint`, `format`, `typecheck`, `knip`, `cycles`, `unit` (sem service), `int` (com service Postgres 17 + migrate + seed:minimal), `build` (com service), `migration-lock` (inalterado salvo fix OPS3). Sem `needs` entre os baratos.
|
||||
- **`.github/workflows/ci.yml`** — adicionar steps `db:seed:minimal` e `build`; preparar para receber e2e full (OPS5).
|
||||
- **`docs/AGENT-OPS.md`** — atualizar tabela "CI por alvo" com a matriz final (incl. e2e por alvo) e o tempo-alvo de feedback.
|
||||
- Sem migration, sem código de app.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **OPS3** (migration-lock funcionando) — dura, senão o CI fica vermelho por infra no PR desta entrega.
|
||||
- OPS5 (affected tests) depende desta — a seleção vira flags nos jobs `int`/`e2e` aqui criados.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Manifesto e2e / `--changed` do vitest → OPS5.
|
||||
- Hooks locais (`gate:fast`/`gate:push`) → OPS6. Docs de política de agente → OPS7.
|
||||
- Nightly e2e full agendado → Adiado (abaixo).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Reusable workflows / composite actions.** Cerimônia YAML sem 3º consumidor. **Mitigação:** decisão travada acima; gatilho de revisitação registrado.
|
||||
- **Shard da suíte int em N workers.** O gargalo medido é serialização, não CPU de um job; sharding adiciona setup de DB por shard. **Mitigação:** adiado com gatilho (int > 15 min no job único por 2 semanas).
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Nightly e2e full + shard de int.** Revisitar quando: job `int` > 15 min sustentado ou e2e full no ci.yml > 25 min.
|
||||
|
||||
## Referências
|
||||
|
||||
- `.github/workflows/ci-pr.yml` (job único atual), `ci.yml`, `ci-stage.yml` (comentário do subset)
|
||||
- `docs/AGENT-OPS.md` — tabela "CI por alvo" e "Contrato de PR"
|
||||
- AGENTS.md / `.cursor/rules/engineering-standards.mdc` — gate em duas velocidades, comandos bare
|
||||
- Run 30558184687 — baseline de wall-clock (9m37s serial até a falha)
|
||||
77
docs/plans/desbloquear-pr50-ci.md
Normal file
77
docs/plans/desbloquear-pr50-ci.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Desbloquear PR #50 — fix migration-lock + spec homeSearchLeaderships seed-proof
|
||||
|
||||
Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano)
|
||||
Atualizado em: 2026-07-30
|
||||
Issue: —
|
||||
Priority: P0
|
||||
Model: kimi-k2.7-code
|
||||
Impeccable: A — N/A (sem superfície UI)
|
||||
Appetite: ~0,5 dia eng; 1 workflow step + 1 repair de spec int + watch de CI
|
||||
Responsável: —
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
Dados: N/A — repair de CI/teste, sem superfície de dados.
|
||||
|
||||
## Contexto
|
||||
|
||||
O PR [#50](https://github.com/fsolla/teqo/pull/50) (`agent/B53-busca-global-demandas`, Issue #7, B53) está `MERGEABLE` mas `BLOCKED` com dois checks vermelhos (run [30558184687](https://github.com/fsolla/teqo/actions/runs/30558184687)):
|
||||
|
||||
1. **`migration-lock` FAIL** — duas causas de infra empilhadas no job em `.github/workflows/ci-pr.yml`: (a) `gh pr list --json number,files` **sem `actions/checkout@v4`** → `failed to run git: fatal: not a git repository`; (b) com o checkout, `GraphQL: Resource not accessible by integration (repository.pullRequests)` → o `GITHUB_TOKEN` padrão precisa de `permissions: pull-requests: read` explícita. Todo PR falhava esse job.
|
||||
2. **`checks` FAIL** — `tests/int/homeSearchLeaderships.int.spec.ts:87`: `expected [ 1, 168, 169 ] to deeply equal [ 168, 169 ]`. O spec consulta `'Lider'` e asserta o conjunto global exato; em ci-pr, `pnpm db:seed:minimal` roda antes do int e semeia `Seed Liderança Um` (`scripts/lib/seed-minimal-manifest.mjs:103`) — word-start `Liderança` casa `Lider`. Em `ci.yml` (main) não há `seed:minimal`, então o defeito só aparece no ci-pr: **todo PR futuro contra `stage` está bloqueado**, não só o #50. O spec de Demandas do próprio PR (`tests/int/homeSearchDemands.int.spec.ts:76`) já usa o padrão correto: token único `fixtures.value('scope-demanda')`.
|
||||
|
||||
Decisão de produto (2026-07-30, brief do lote CI): done = **PR #50 mergeado em `stage` com CI verde**, não "feature implementada".
|
||||
|
||||
## Objetivos
|
||||
|
||||
- `gh pr checks 50` todo verde e merge automático em `stage` efetivado.
|
||||
- `migration-lock` volta a exercer a regra real (≤1 PR tocando schema) em vez de falhar por infra.
|
||||
- `homeSearchLeaderships.int.spec.ts` fica seed-proof: passa com e sem `db:seed:minimal` no banco.
|
||||
- Sem migration, sem schema, sem mudança de comportamento de produto.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Fix do spec no padrão token-único, não filtro-por-ids nem exclusão do seed.** O padrão `fixtures.value(...)` já existe no spec irmão (demandas) e mantém a asserção de conjunto exato (que pega regressão de escopo). **Rejeitado:** filtrar o resultado aos ids criados pela fixture (enfraquece a asserção de escopo do coordinator); remover as lideranças do `seed-minimal-manifest` (o contrato do DB mínimo — pinado por `tests/unit/seedMinimalManifest.unit.spec.ts` — existe para int specs que dependem de seed; quebraria outros consumidores).
|
||||
- **Fix do `migration-lock` = `actions/checkout@v4` + `permissions: { contents: read, pull-requests: read }` no job.** As duas causas foram confirmadas em runs consecutivos (log do run 30561456683: GraphQL permission error após o checkout). **Rejeitado:** token dedicado/PAT (GITHUB_TOKEN com grant explícito é o padrão e não adiciona secret).
|
||||
- **Fix vai na branch do próprio PR #50** (`agent/B53-busca-global-demandas`), não em branch separada: a política "CI vermelho = seu problema" (OPS7) começa valendo aqui. **Rejeitado:** PR separado de infra primeiro (duplo round-trip de CI; o #50 já é o PR afetado e está `MERGEABLE`).
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **O spec `homeSearchStateDeputies`/`homeSearchAdvisors` etc. têm o mesmo padrão frágil?** **Opções:** A) auditar e corrigir todos neste PR | B) corrigir só o que falha e registrar o resto como débito. **Recomendação:** A se o audit mostrar o mesmo padrão de nome fixo + assert global (grep por `toEqual([` em `homeSearch*.int.spec.ts`) — custo marginal dentro do appetite, evita o próximo bloqueio; senão B.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`.github/workflows/ci-pr.yml`** — job `migration-lock`: inserir `- uses: actions/checkout@v4` como primeiro step.
|
||||
- **`tests/int/homeSearchLeaderships.int.spec.ts`** — trocar nomes fixos (`Lider Cairu`, `Lider Feira`, `Zeca Pagodinho`, `Maria Silva`) por tokens únicos via `fixtures.value(...)` e usar o token como query (espelhando `homeSearchDemands.int.spec.ts:76-94`); manter asserts de conjunto exato.
|
||||
- **Audit grep** — `rg "toEqual\(\[" tests/int/homeSearch*.int.spec.ts` para a questão em aberto acima.
|
||||
|
||||
Sem migration, sem collection, sem server action.
|
||||
|
||||
## Dependências
|
||||
|
||||
Nenhuma de outro plano. Reusa `installCampaignFixtures` (`tests/helpers/campaignFixtures.ts`) e o padrão `fixtures.value` já existente.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Refatoração estrutural do ci-pr (jobs paralelos) → OPS4.
|
||||
- Affected tests / manifesto e2e → OPS5.
|
||||
- Pre-push hooks → OPS6. Política de agente em docs → OPS7 (texto novo; a **prática** vale neste PR).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que toco o workflow, paralelizo logo".** Explode OPS4 dentro do P0 e atrasa o desbloqueio. **Mitigação:** diff do workflow limitado ao step de checkout.
|
||||
- **Investigar por que `ci-stage` smoke não pegou isso.** O smoke roda contra Neon com subset curado; a falha é do ambiente mínimo. **Mitigação:** registrar observação no plano OPS4, não investigar aqui.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
Nenhum neste item.
|
||||
|
||||
## Referências
|
||||
|
||||
- GitHub Issue #7 (B53), PR #50, run 30558184687 (logs das duas falhas)
|
||||
- `.github/workflows/ci-pr.yml` — job `migration-lock`
|
||||
- `tests/int/homeSearchLeaderships.int.spec.ts` + `tests/int/homeSearchDemands.int.spec.ts` (padrão-alvo)
|
||||
- `scripts/lib/seed-minimal-manifest.mjs` — `MINIMAL_LEADERSHIPS`
|
||||
- `.cursor/skills/work-issue/SKILL.md` — Passo 6 (acompanhar checks até o merge)
|
||||
73
docs/plans/gate-push-local.md
Normal file
73
docs/plans/gate-push-local.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Gate local DRY — gate:fast / gate:push + pre-push completo
|
||||
|
||||
Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano)
|
||||
Atualizado em: 2026-07-30
|
||||
Issue: —
|
||||
Priority: P2
|
||||
Model: cursor-grok-4.5-high
|
||||
Impeccable: A — N/A (sem superfície UI)
|
||||
Appetite: ~0,5 dia eng; 2 scripts npm + 1 hook + docs
|
||||
Responsável: —
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
Dados: N/A — chore de DX.
|
||||
|
||||
## Contexto
|
||||
|
||||
`.husky/pre-push` é uma linha: `pnpm lint && pnpm format:check && pnpm typecheck`. Mas o fast gate exigido por `.cursor/rules/engineering-standards.mdc` (desde 2026-07-30) e por `work-issue` Passo 4.3 é **lint + tsc + test:unit + format:check + check:cycles**. Resultado: agentes e humanos pusham verde no hook e quebram no CI por unit/cycles que nunca rodaram localmente. Não existe pre-commit hook.
|
||||
|
||||
Decisão de produto (2026-07-30, brief do lote CI): scripts DRY (`gate:fast`, `gate:push`), divisão pre-commit vs pre-push explícita, escape hatch documentado.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- `.husky/pre-push` roda o fast gate completo via script npm único (DRY com a documentação).
|
||||
- `package.json`: `gate:fast` (lint + typecheck + test:unit) e `gate:push` (gate:fast + format:check + check:cycles).
|
||||
- Escape hatch documentado (`git push --no-verify`), com aviso de que CI repete tudo.
|
||||
- Docs (`engineering-standards.mdc`, `AGENT-OPS.md`) citam os scripts, não a lista de comandos — uma fonte só.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **knip fora do pre-push.** O grafo do knip é incompleto (não carrega `payload.config.ts`, ledger P3) e é lento para um hook; CI já o roda como erro bloqueante. **Rejeitado:** knip no hook (custo por push + falsos positivos já fichados); knip no `gate:fast` documentado como obrigatório (mesma razão).
|
||||
- **Sem pre-commit hook.** A divisão fica: nada no commit (commits intermediários de WIP são legítimos), tudo no push. **Rejeitado:** lint-staged no commit (adiciona toolchain e atrito sem pegar classe de erro que o push não pegue segundos depois).
|
||||
- **Escape hatch = `--no-verify` nativo, documentado; sem variável mágica própria.** **Rejeitado:** `SKIP_GATE=1` (mais uma convenção para manter; `--no-verify` é o padrão git que todo agente/humano já conhece).
|
||||
- **Hook chama `pnpm gate:push`, nunca inline de comandos.** Uma fonte de verdade editável em `package.json`. **Rejeitado:** manter a linha de shell no hook (foi o que causou o drift atual).
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **`pnpm format` auto-fix no hook em vez de `format:check`?** **Opções:** A) check falha e o autor roda `pnpm format` | B) hook formata e aborta para re-add. **Recomendação:** A — hook que edita a worktree surpreende; o erro do check já diz o remédio.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`package.json`** — scripts `gate:fast` e `gate:push` (comandos bare encadeados com `&&`, nunca piped — invariante do repo).
|
||||
- **`.husky/pre-push`** — `pnpm gate:push`.
|
||||
- **`.cursor/rules/engineering-standards.mdc`** — item 3 do "After every change" passa a citar `pnpm gate:fast` / `pnpm gate:push`.
|
||||
- **`docs/AGENT-OPS.md`** — linha do fast gate local atualizada para os scripts.
|
||||
- **`.cursor/skills/work-issue/SKILL.md`** — Passo 4.3/6.1 cita `pnpm gate:push`.
|
||||
|
||||
Sem migration, sem código de app.
|
||||
|
||||
## Dependências
|
||||
|
||||
Nenhuma de outro plano (independente; pode rodar em paralelo com OPS3/OPS4). Conflito potencial de merge com OPS7 em `AGENT-OPS.md`/`engineering-standards.mdc` — serializar na fila ou aceitar rebase trivial.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Mudar o que o CI roda → OPS4/OPS5. Política "CI vermelho = seu problema" → OPS7.
|
||||
- Husky v9 deprecations / migração de tooling de hooks.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que toco hooks, adiciono lint-staged/commitlint".** Toolchain nova para problema que não temos. **Mitigação:** decisão travada; commitlint só se 3 mensagens fora-do-padrão quebrarem automação (nenhuma hoje).
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
Nenhum neste item.
|
||||
|
||||
## Referências
|
||||
|
||||
- `.husky/pre-push` (linha atual), `package.json` (scripts de gate existentes)
|
||||
- `.cursor/rules/engineering-standards.mdc` — "Gate em duas velocidades"
|
||||
- `docs/AGENT-OPS.md` — fast gate local; `.cursor/skills/work-issue/SKILL.md` — Passo 4.3
|
||||
75
docs/plans/politica-ci-dono-do-pr.md
Normal file
75
docs/plans/politica-ci-dono-do-pr.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Política de agente — CI vermelho é do dono do PR até o merge
|
||||
|
||||
Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano)
|
||||
Atualizado em: 2026-07-30
|
||||
Issue: —
|
||||
Priority: P2
|
||||
Model: cursor-grok-4.5-high
|
||||
Impeccable: A — N/A (sem superfície UI)
|
||||
Appetite: ~0,5 dia; docs em 3 arquivos
|
||||
Responsável: —
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
Dados: N/A — política de fluxo em docs.
|
||||
|
||||
## Contexto
|
||||
|
||||
Incidente (2026-07-30, PR #50): o agente em `work-issue` desistiu ao ver (a) `migration-lock` falhando por defeito de infra (job sem `checkout`) e (b) um teste int falhando fora do escopo nominal da feature (`homeSearchLeaderships`, confundido com e2e). A feature estava pronta; o PR ficou `BLOCKED` e ninguém era dono. Lacuna: nem `work-issue` nem `engineering-standards` dizem quem conserta CI quebrado "fora da feature".
|
||||
|
||||
Decisão de produto (2026-07-30, brief do lote CI): **done = PR mergeado em `stage` com CI verde** — corrigir qualquer falha de CI necessária ao merge, mesmo fora da feature.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- `work-issue`, `engineering-standards.mdc` e `docs/AGENT-OPS.md` declaram a política com o mesmo texto-núcleo (uma fonte, duas citações).
|
||||
- Regra de triagem da falha de CI documentada: infra do workflow / teste pré-existente / regressão da feature — as três são do dono do PR; o que muda é o fix.
|
||||
- Registro do incidente #50 como precedente em `docs/CHANGELOG-AGENTS.md` (uma entrada curta, formato do arquivo).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Dono do PR conserta, sem exceção por escopo.** Racional: com agentes paralelos, "não é minha feature" vira deadlock (o #50 ficou parado por isso). O autor já tem branch, contexto e CI loop aberto. **Rejeitado:** abrir Issue separada e seguir (o PR apodrece BLOCKED — exatamente o incidente); esperar humano (derrota a autonomia do paradigma AGENT-OPS).
|
||||
- **Limite explícito: o fix colateral segue as mesmas regras de blast radius** — migration/access/Consent continuam proibidos de "consertar de passagem" sem plano; nesses casos o agente para e escala (Opções documentadas). **Rejeitado:** carta branca para qualquer fix (contradiz as invariantes de segurança do AGENTS.md).
|
||||
- **Uma fonte de verdade (AGENT-OPS), citações nos outros dois** — evita o drift que o pre-push sofreu (OPS6). **Rejeitado:** repetir o parágrafo inteiro nos três arquivos.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Teste flaky (não determinístico) conta como "sua responsabilidade"?** **Opções:** A) sim, repair ou quarentena com justificativa no PR | B) retry até passar. **Recomendação:** A — retry cega o CI para flakes reais; quarentena exige comentário + débito registrado via `capture-review-debts`.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
Componentes (texto, não código):
|
||||
|
||||
- **`docs/AGENT-OPS.md`** — novo bloco na seção "Fluxo": "Dono do PR, dono do CI" (definição de done, triagem das 3 classes de falha, limite de blast radius, flaky→quarentena+débito).
|
||||
- **`.cursor/skills/work-issue/SKILL.md`** — Passo 6.5 reforçado: falha no ci-pr → corrige na mesma branch **qualquer que seja a origem**, citando AGENT-OPS; "fora do escopo" não é critério de parada.
|
||||
- **`.cursor/rules/engineering-standards.mdc`** — linha no "After every change": CI vermelho no seu PR é seu, mesmo pré-existente.
|
||||
- **`docs/CHANGELOG-AGENTS.md`** — entrada do incidente PR #50 → esta política.
|
||||
|
||||
Texto-núcleo proposto (núcleo único citado nos três):
|
||||
|
||||
> Done = PR mergeado em `stage` com CI verde. Falha de CI no teu PR — infra do workflow, teste pré-existente ou regressão da feature — é tua até o merge. "Fora do escopo da feature" não é motivo para parar: corrige na mesma branch. Exceção de blast radius: se o fix exigir migration, access control ou Consent, para e escala com Opções — esses nunca se corrigem "de passagem". Flaky não se resolve com retry: repair ou quarentena justificada + débito registrado.
|
||||
|
||||
Sem migration, sem código de app.
|
||||
|
||||
## Dependências
|
||||
|
||||
Nenhuma dura. Sinérgica com OPS3 (que aplica a política na prática) e OPS6 (mesma família de arquivos — serializar para evitar conflito trivial).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Mudança de gates/hooks → OPS6. Refatoração de workflows → OPS4/OPS5.
|
||||
- `agent:file-miss` do incidente: o incidente vira precedente documentado aqui; se o humano quiser também como `kind:agent-miss` para o harvest OPS2 (#43), registrar lá.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Reescrever work-issue inteira "de passagem".** A skill está estável; o delta é uma política. **Mitigação:** diff limitado ao Passo 6 + citação.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
Nenhum neste item.
|
||||
|
||||
## Referências
|
||||
|
||||
- Incidente: PR #50, run 30558184687 (migration-lock sem checkout; int fora da feature)
|
||||
- `docs/AGENT-OPS.md` — seção "Fluxo"; `.cursor/skills/work-issue/SKILL.md` — Passo 6
|
||||
- `.cursor/rules/engineering-standards.mdc` — "After every change"
|
||||
- Issue #43 (OPS2) — harvest agent-miss → guardrails
|
||||
94
docs/plans/testes-afetados-pr.md
Normal file
94
docs/plans/testes-afetados-pr.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Testes afetados pelo diff no PR — vitest --changed + manifesto path→spec e2e
|
||||
|
||||
Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano)
|
||||
Atualizado em: 2026-07-30
|
||||
Issue: —
|
||||
Priority: P1
|
||||
Model: composer-2.5
|
||||
Impeccable: A — N/A (sem superfície UI)
|
||||
Appetite: ~1–2 dias eng; script de seleção + manifesto + wiring nos jobs do OPS4 + pins
|
||||
Responsável: —
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
Dados: N/A — infra de CI.
|
||||
|
||||
## Contexto
|
||||
|
||||
Com o ci-pr paralelizado (OPS4), o próximo corte é rodar **só o que o diff toca** em PRs de feature branch: hoje um PR de copy roda 523 testes int + 522 unit + build. Vitest suporta `--changed` contra uma base git; Playwright `--only-changed` **não** cobre mudanças em `src/` (specs e2e não importam o app), então a seleção e2e precisa de um **manifesto path → spec** curado no repo. Push em `stage`/`main` sempre roda suíte completa (decisão OPS4).
|
||||
|
||||
Decisão de produto (2026-07-30, brief do lote CI): fallback para suíte completa quando o diff tocar paths de alto risco — migrations, lockfile, configs de teste, seed manifest, collections/globals.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Job `unit`/`int` no ci-pr roda `vitest --changed origin/$GITHUB_BASE_REF` (fallback full nos paths de risco).
|
||||
- Job `e2e` no ci-pr roda specs mapeadas pelo manifesto; fallback full idem.
|
||||
- `scripts/e2e-affected.mjs` (ou equivalente) imprime a lista de specs e o motivo (decisão auditável no log do CI).
|
||||
- Pins: unit test do manifesto (path→spec existe, spec existe no disco) + comportamento de fallback.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Manifesto e2e = dados no repo, não inferência por import.** e2e não importa `src/`; a única fonte de verdade é uma tabela curada. **Rejeitado:** heurística por nome de spec (frágil — `campaignZoneMap` ≠ pasta `map/`); trace estático Playwright→app (não existe aresta de import).
|
||||
- **Fallback full em paths de alto risco** (lista congelada no script): `src/migrations/`, `src/payload-types.ts`, `pnpm-lock.yaml`, `package.json`, `vitest*.config.*`, `playwright.config.ts`, `scripts/lib/seed-minimal-manifest.mjs`, `scripts/seed-minimal.mjs`, `src/collections/`, `src/globals/`, `src/payload.config.ts`, `tests/helpers/`, `tests/e2e/fixtures/`, `.env*`. **Rejeitado:** fallback por "mais de N arquivos" (tamanho ≠ risco); lista aberta por glob no workflow (regra fora de teste unitário).
|
||||
- **Seleção só no ci-pr; `ci.yml`/`ci-stage.yml` sempre full.** Rede de segurança por alvo (decisão OPS4). **Rejeitado:** affected também no main (derrota o propósito do gate pós-merge).
|
||||
- **`--changed` com fallback para full quando vitest retorna conjunto vazio mas o diff toca `src/`** (ex.: arquivo novo sem spec importando). **Rejeitado:** aceitar conjunto vazio como "nada a rodar" (falso verde em código novo sem teste ligado).
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **e2e bloqueante no PR desde o dia 1?** **Decidido no gate (humano, 2026-07-30): bloqueante já.** A alternativa de 2 semanas em `continue-on-error` para calibrar o manifesto foi rejeitada pelo humano ("já resolve logo nessa sessão"). O manifesto fica visível nos logs (`unmapped`) para calibração contínua.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
diff["git diff origin/base...HEAD"] --> script["scripts/e2e-affected.mjs + scripts/test-affected.mjs"]
|
||||
script -->|paths de risco| full[suites completas]
|
||||
script -->|paths mapeados| sel["specs selecionadas + vitest --changed"]
|
||||
sel --> jobs["jobs unit/int/e2e do ci-pr"]
|
||||
full --> jobs
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`scripts/test-affected.mjs`** — computa diff vs `GITHUB_BASE_REF` (default `origin/stage`), aplica a lista de risco, emite `{ mode: 'full' | 'changed' }` + args vitest. Consumido pelos jobs `unit` e `int`.
|
||||
- **`scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`** — tabela prefixo→specs. Esboço inicial (17 specs em `tests/e2e/`):
|
||||
- `src/app/(campaign)/campanha/(app)/municipios`, `src/components/campaign/municipality`, `src/utilities/municipality/` → `campaignMunicipalities`, `campaignZoneMap`, `campaignNearestMunicipality`, `campaignSavedFilters`, `campaignColumnPicker`
|
||||
- `…/atividades`, `components/campaign/activity` → `campaignActivity`
|
||||
- `…/liderancas`, `components/campaign/leadership` → `campaignLeaderships`
|
||||
- `…/territorios`, `components/campaign/territory` → `campaignTerritories`
|
||||
- `campanha/login`, `utilities/campaignAuth`, `utilities/webauthn/` → `campaignAuth`, `campaignWebAuthn`
|
||||
- `…/conceitos` → `campaignConcepts`; dashboard/home → `campaignHomeActions`
|
||||
- `components/campaign/shell`, PWA → `campaign-pwa`, `campaignWizardChrome`
|
||||
- `src/app/(payload)` → `admin`; `src/app/(frontend)` → `frontend`
|
||||
- qualquer outro `src/` não mapeado → log explícito "sem spec mapeada" (modo e2e: nenhuma; o gap fica visível no log para o humano julgar — decisão: não falhar, e2e é camada fina sobre int).
|
||||
- **`.github/workflows/ci-pr.yml`** — jobs `unit`/`int`/`e2e` (do OPS4) consomem os scripts; step de diff com `fetch-depth: 0` no checkout.
|
||||
- **Tests** — `tests/unit/testAffected.unit.spec.ts` + `tests/unit/e2eAffectedManifest.unit.spec.ts` (manifesto aponta para specs existentes; paths de risco disparam full).
|
||||
|
||||
Sem migration, sem código de app.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **OPS4** (jobs paralelos existem para receber a seleção) — dura.
|
||||
- Reusa: padrão `scripts/lib/` (B5 F2), `tests/helpers/assertTestDatabase.ts` não é tocado.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Seleção de unit por manifesto (vitest `--changed` resolve por import graph real).
|
||||
- Cache de resultados de teste entre runs (Turborepo-style) — sem evidência de necessidade.
|
||||
- Nightly full e2e → OPS4 (adiado lá).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Manifesto vira segundo grafo de dependências.** Se alguém "só completar" granularidade por arquivo, vira manutenção morta. **Mitigação:** granularidade de prefixo de domínio; pin de unit test só na existência, não na completude.
|
||||
- **`vitest --changed` com worktrees/forks sem base fetchada.** `fetch-depth: 0` no checkout; fallback full se o merge-base não resolver (log + exit code do script).
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Completude do manifesto enforced (falhar CI quando `src/` novo não mapeia).** Revisitar quando: 3º fallback full causado por prefixo ausente.
|
||||
|
||||
## Referências
|
||||
|
||||
- `tests/e2e/` (17 specs + fixtures), `playwright.config.ts`, `vitest.unit.config.mts`, `vitest.config.mts`
|
||||
- `.github/workflows/ci-pr.yml` (jobs do OPS4)
|
||||
- `docs/AGENT-OPS.md` — linha E2E da tabela "CI por alvo" (atualizar neste item)
|
||||
- Vitest `--changed`: https://vitest.dev/guide/cli.html#changed
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export default defineConfig({
|
|||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
reporter: process.env.CI ? [['list'], ['github'], ['html', { open: 'never' }]] : 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
|
|
@ -90,7 +90,16 @@ export default defineConfig({
|
|||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
/*
|
||||
* CI and E2E_PROD=1 serve the PRODUCTION build (`pnpm start` after a
|
||||
* `pnpm build` step in the e2e job): dev-mode cold webpack compiles per
|
||||
* route plus dev-server memory restarts made the full suite blow the 30-min
|
||||
* job timeout on CI (measured 2026-07-30). The municipalities-list React
|
||||
* #130 was fixed by sharing one Drawer across the mobile cards
|
||||
* (CampaignListSheetProvider, 2026-07-30). Locally `pnpm dev` stays the
|
||||
* default: no build wait and hot reload while writing specs.
|
||||
*/
|
||||
command: process.env.CI || process.env.E2E_PROD === '1' ? 'pnpm start' : 'pnpm dev',
|
||||
/*
|
||||
* Never reuse a dev server a developer may already have running against the
|
||||
* production database — always start a fresh one bound to the test DB.
|
||||
|
|
|
|||
53
scripts/e2e-affected.mjs
Normal file
53
scripts/e2e-affected.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* e2e-affected (OPS5) — decides the Playwright scope for a PR diff via the
|
||||
* curated manifest and prints JSON on stdout:
|
||||
* { mode, specs, reason, unmapped, base }.
|
||||
*
|
||||
* mode=full → run the whole e2e suite
|
||||
* mode=selected → run tests/e2e/<name>.e2e.spec.ts for each name in `specs`
|
||||
* mode=none → skip e2e for this diff
|
||||
*
|
||||
* `unmapped` src/ files go to stderr as a warning — never a failure (e2e is a
|
||||
* thin layer over int; a human judges whether the manifest needs the prefix).
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { E2E_AFFECTED_MANIFEST } from './lib/e2e-affected-manifest.mjs'
|
||||
import { selectE2eSpecs } from './lib/test-affected-core.mjs'
|
||||
|
||||
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'stage'}`
|
||||
|
||||
let mergeBase
|
||||
try {
|
||||
mergeBase = execFileSync('git', ['merge-base', 'HEAD', base], { encoding: 'utf8' }).trim()
|
||||
} catch {
|
||||
const result = {
|
||||
mode: 'full',
|
||||
specs: [],
|
||||
reason: `no merge-base with ${base} (shallow clone?)`,
|
||||
unmapped: [],
|
||||
base,
|
||||
}
|
||||
console.log(JSON.stringify(result))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const raw = execFileSync('git', ['diff', '--name-status', `${mergeBase}...HEAD`], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const files = raw
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [status, path] = line.split('\t')
|
||||
return { status, path }
|
||||
})
|
||||
|
||||
const result = { ...selectE2eSpecs(files, E2E_AFFECTED_MANIFEST), base }
|
||||
if (result.unmapped.length > 0) {
|
||||
console.error(
|
||||
`[e2e-affected] src/ paths with no manifest mapping:\n ${result.unmapped.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
console.log(JSON.stringify(result))
|
||||
85
scripts/lib/e2e-affected-manifest.mjs
Normal file
85
scripts/lib/e2e-affected-manifest.mjs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* e2e manifest (OPS5): source path prefix → Playwright specs exercising it.
|
||||
* e2e specs never import app code, so this curated table is the only source
|
||||
* of truth. Granularity is the DOMAIN prefix on purpose — finer grains rot.
|
||||
* Spec names exist as tests/e2e/<name>.e2e.spec.ts (pinned by unit test).
|
||||
* Prefixes are repo-relative POSIX paths; matching is startsWith.
|
||||
*/
|
||||
const CAMPAIGN_APP = 'src/app/(campaign)/campanha/(app)'
|
||||
|
||||
export const E2E_AFFECTED_MANIFEST = [
|
||||
{
|
||||
prefixes: ['src/app/(payload)'],
|
||||
specs: ['admin'],
|
||||
},
|
||||
{
|
||||
prefixes: ['src/app/(frontend)'],
|
||||
specs: ['frontend'],
|
||||
},
|
||||
{
|
||||
prefixes: ['src/app/(campaign)/campanha/login', 'src/utilities/campaignAuth'],
|
||||
specs: ['campaignAuth'],
|
||||
},
|
||||
{
|
||||
prefixes: ['src/app/(campaign)/campanha/webauthn', 'src/utilities/webauthn'],
|
||||
specs: ['campaignWebAuthn'],
|
||||
},
|
||||
{
|
||||
prefixes: [
|
||||
`${CAMPAIGN_APP}/municipios`,
|
||||
'src/components/campaign/municipality',
|
||||
'src/components/campaign/map',
|
||||
'src/utilities/municipality',
|
||||
],
|
||||
specs: [
|
||||
'campaignMunicipalities',
|
||||
'campaignZoneMap',
|
||||
'campaignNearestMunicipality',
|
||||
'campaignSavedFilters',
|
||||
'campaignColumnPicker',
|
||||
],
|
||||
},
|
||||
{
|
||||
prefixes: [
|
||||
`${CAMPAIGN_APP}/atividades`,
|
||||
'src/components/campaign/activity',
|
||||
'src/utilities/activity',
|
||||
],
|
||||
specs: ['campaignActivity'],
|
||||
},
|
||||
{
|
||||
prefixes: [
|
||||
`${CAMPAIGN_APP}/liderancas`,
|
||||
'src/components/campaign/leadership',
|
||||
'src/utilities/leadership',
|
||||
],
|
||||
specs: ['campaignLeaderships'],
|
||||
},
|
||||
{
|
||||
prefixes: [`${CAMPAIGN_APP}/territorios`, 'src/utilities/territory'],
|
||||
specs: ['campaignTerritories'],
|
||||
},
|
||||
{
|
||||
prefixes: [`${CAMPAIGN_APP}/conceitos`],
|
||||
specs: ['campaignConcepts'],
|
||||
},
|
||||
{
|
||||
prefixes: [
|
||||
`${CAMPAIGN_APP}/page.tsx`,
|
||||
`${CAMPAIGN_APP}/home-search`,
|
||||
'src/components/campaign/dashboard',
|
||||
'src/utilities/homeSearch',
|
||||
'src/utilities/campaignDashboardData',
|
||||
'src/lib/campaignHomeSearchHits',
|
||||
],
|
||||
specs: ['campaignHomeActions'],
|
||||
},
|
||||
{
|
||||
prefixes: [
|
||||
'src/components/campaign/shell',
|
||||
`${CAMPAIGN_APP}/layout.tsx`,
|
||||
'src/utilities/campaignPwa',
|
||||
],
|
||||
specs: ['campaign-pwa', 'campaignWizardChrome'],
|
||||
},
|
||||
]
|
||||
115
scripts/lib/test-affected-core.mjs
Normal file
115
scripts/lib/test-affected-core.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Pure selection logic for affected-test CI jobs (OPS5). No git, no fs, no
|
||||
* process — the CLI wrappers feed it the changed file list and it decides.
|
||||
* Unit-pinned in tests/unit/testAffected.unit.spec.ts and
|
||||
* tests/unit/e2eAffectedManifest.unit.spec.ts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Paths whose blast radius is the whole app: schema, test harness, lockfile.
|
||||
* Any diff touching them runs the FULL suites — selection is unsafe there.
|
||||
*/
|
||||
export const HIGH_RISK_EXACT = new Set([
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'playwright.config.ts',
|
||||
'vitest.config.mts',
|
||||
'vitest.unit.config.mts',
|
||||
'vitest.setup.ts',
|
||||
'src/payload-types.ts',
|
||||
'src/payload.config.ts',
|
||||
'scripts/seed-minimal.mjs',
|
||||
'scripts/lib/seed-minimal-manifest.mjs',
|
||||
'.env.test',
|
||||
])
|
||||
|
||||
export const HIGH_RISK_PREFIXES = [
|
||||
'src/migrations/',
|
||||
'src/collections/',
|
||||
'src/globals/',
|
||||
'tests/helpers/',
|
||||
'tests/e2e/fixtures/',
|
||||
]
|
||||
|
||||
function isHighRisk(path) {
|
||||
return HIGH_RISK_EXACT.has(path) || HIGH_RISK_PREFIXES.some((prefix) => path.startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide vitest scope for a PR diff.
|
||||
* @param {{ path: string, status: string }[]} files git diff --name-status rows.
|
||||
* @returns {{ mode: 'full' | 'changed' | 'none', reason: string }}
|
||||
* full — high-risk path or new src file (nothing imports it yet, so
|
||||
* --changed cannot find it; silent green otherwise);
|
||||
* changed — vitest --changed <base> is meaningful;
|
||||
* none — no src/tests touched (docs-only PR); skip the jobs.
|
||||
*/
|
||||
export function classifyTestScope(files) {
|
||||
if (files.some(({ path }) => isHighRisk(path))) {
|
||||
return { mode: 'full', reason: 'diff touches a high-risk path (schema/lockfile/test harness)' }
|
||||
}
|
||||
if (files.some(({ path, status }) => status === 'A' && path.startsWith('src/'))) {
|
||||
return { mode: 'full', reason: 'new src/ file — vitest --changed cannot trace tests to it yet' }
|
||||
}
|
||||
if (files.some(({ path }) => path.startsWith('src/') || path.startsWith('tests/'))) {
|
||||
return { mode: 'changed', reason: 'src/tests diff without high-risk paths' }
|
||||
}
|
||||
return { mode: 'none', reason: 'no src/ or tests/ changes' }
|
||||
}
|
||||
|
||||
const E2E_SPEC_PATTERN = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/
|
||||
|
||||
/**
|
||||
* Decide the Playwright scope for a PR diff.
|
||||
* @param {{ path: string, status: string }[]} files
|
||||
* @param {{ prefixes: string[], specs: string[] }[]} manifest prefix → spec names.
|
||||
* @returns {{ mode: 'full' | 'selected' | 'none', specs: string[], reason: string,
|
||||
* unmapped: string[] }}
|
||||
* `specs` are spec NAMES (no path, no .e2e.spec.ts). `unmapped` lists src/
|
||||
* files with no manifest prefix — logged in CI, never a failure (e2e is a
|
||||
* thin layer over int; the gap is for a human to judge).
|
||||
*/
|
||||
export function selectE2eSpecs(files, manifest) {
|
||||
if (files.some(({ path }) => isHighRisk(path))) {
|
||||
return {
|
||||
mode: 'full',
|
||||
specs: [],
|
||||
reason: 'diff touches a high-risk path (schema/lockfile/test harness)',
|
||||
unmapped: [],
|
||||
}
|
||||
}
|
||||
const specs = new Set()
|
||||
const unmapped = []
|
||||
for (const { path } of files) {
|
||||
if (E2E_SPEC_PATTERN.test(path)) {
|
||||
specs.add(path.slice('tests/e2e/'.length, -'.e2e.spec.ts'.length))
|
||||
continue
|
||||
}
|
||||
if (!path.startsWith('src/')) continue
|
||||
const matches = manifest.filter((entry) =>
|
||||
entry.prefixes.some((prefix) => path.startsWith(prefix)),
|
||||
)
|
||||
if (matches.length === 0) {
|
||||
unmapped.push(path)
|
||||
continue
|
||||
}
|
||||
for (const match of matches) for (const spec of match.specs) specs.add(spec)
|
||||
}
|
||||
if (specs.size === 0) {
|
||||
return {
|
||||
mode: 'none',
|
||||
specs: [],
|
||||
reason:
|
||||
unmapped.length > 0
|
||||
? 'src/ changes with no e2e manifest mapping (see unmapped list)'
|
||||
: 'no e2e-relevant changes',
|
||||
unmapped,
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'selected',
|
||||
specs: [...specs].sort(),
|
||||
reason: `${specs.size} spec(s) selected via manifest and changed specs`,
|
||||
unmapped,
|
||||
}
|
||||
}
|
||||
64
scripts/run-e2e-affected.mjs
Normal file
64
scripts/run-e2e-affected.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* run-e2e-affected — local mirror of the CI (PR) e2e job:
|
||||
* 1. `e2e-affected.mjs` picks full vs manifest-selected vs skip (same base ref).
|
||||
* 2. migrate + db:seed:minimal when anything would run.
|
||||
* 3. optional production build into `.next/e2e` when `E2E_PROD=1`.
|
||||
* 4. `pnpm test:e2e` (set `CI=1` and `E2E_PROD=1` together for prod mode).
|
||||
*
|
||||
* Extra Playwright args after `--` are forwarded (e.g. a single spec while debugging).
|
||||
*
|
||||
* GITHUB_BASE_REF=stage node scripts/run-e2e-affected.mjs
|
||||
* pnpm test:e2e:affected -- tests/e2e/campaignHomeActions.e2e.spec.ts
|
||||
*/
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
const passthroughIndex = process.argv.indexOf('--')
|
||||
const passthroughArgs = passthroughIndex === -1 ? [] : process.argv.slice(passthroughIndex + 1)
|
||||
|
||||
const scopeRaw = execFileSync('node', ['scripts/e2e-affected.mjs'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
})
|
||||
const scope = JSON.parse(scopeRaw.trim())
|
||||
|
||||
console.log(`[e2e:affected] ${scope.reason} (base ${scope.base})`)
|
||||
|
||||
if (scope.mode === 'none') {
|
||||
console.log('[e2e:affected] Nothing to run for this diff.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const run = (command, args, env = {}) => {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...env },
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
run('pnpm', ['migrate'])
|
||||
run('pnpm', ['db:seed:minimal'])
|
||||
if (process.env.E2E_PROD === '1') {
|
||||
run('pnpm', ['build'], { NEXT_DIST_DIR: '.next/e2e' })
|
||||
}
|
||||
|
||||
const playwrightArgs = ['test:e2e']
|
||||
if (scope.mode === 'selected' && scope.specs.length > 0) {
|
||||
for (const name of scope.specs) {
|
||||
playwrightArgs.push(`tests/e2e/${name}.e2e.spec.ts`)
|
||||
}
|
||||
}
|
||||
playwrightArgs.push(...passthroughArgs)
|
||||
|
||||
run('pnpm', playwrightArgs, {
|
||||
...(process.env.E2E_PROD === '1' ? { CI: '1', E2E_PROD: '1' } : {}),
|
||||
})
|
||||
39
scripts/test-affected.mjs
Normal file
39
scripts/test-affected.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* test-affected (OPS5) — decides the vitest scope for a PR diff and prints it
|
||||
* as JSON on stdout: { mode, reason, base }.
|
||||
*
|
||||
* mode=full → run the whole suite (high-risk path / new src file)
|
||||
* mode=changed → run `vitest --changed <base>`
|
||||
* mode=none → nothing to run (docs-only diff); CI skips the step
|
||||
*
|
||||
* Base ref: $GITHUB_BASE_REF (PR target) or origin/stage locally. No DB, no
|
||||
* Payload — pure git + the pure core in scripts/lib/test-affected-core.mjs.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { classifyTestScope } from './lib/test-affected-core.mjs'
|
||||
|
||||
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'stage'}`
|
||||
|
||||
let mergeBase
|
||||
try {
|
||||
mergeBase = execFileSync('git', ['merge-base', 'HEAD', base], { encoding: 'utf8' }).trim()
|
||||
} catch {
|
||||
const result = { mode: 'full', reason: `no merge-base with ${base} (shallow clone?)`, base }
|
||||
console.log(JSON.stringify(result))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const raw = execFileSync('git', ['diff', '--name-status', `${mergeBase}...HEAD`], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const files = raw
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [status, path] = line.split('\t')
|
||||
return { status, path }
|
||||
})
|
||||
|
||||
console.log(JSON.stringify({ ...classifyTestScope(files), base }))
|
||||
|
|
@ -77,8 +77,12 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
>
|
||||
<body className="antialiased w-screen scrollbar-hide overflow-hidden">
|
||||
<ThemeProvider attribute="class">
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
{process.env.VERCEL === '1' ? (
|
||||
<>
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
</>
|
||||
) : null}
|
||||
<RefreshRouteOnSave />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@ import type { LucideIcon } from 'lucide-react'
|
|||
import Link from 'next/link'
|
||||
import { useCallback, useId, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
|
|
@ -149,11 +148,7 @@ export const CampaignHomeActionButton = ({
|
|||
<DrawerDescription id={descriptionId}>{description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerFooter>
|
||||
<DrawerClose
|
||||
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
|
||||
>
|
||||
Fechar
|
||||
</DrawerClose>
|
||||
<DrawerCloseButton>Fechar</DrawerCloseButton>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
|
||||
import { SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import {
|
||||
advisorEntriesFromIds,
|
||||
formatAdvisorNamesTooltip,
|
||||
MissingAdvisorBadge,
|
||||
MunicipalityAdvisorAvatarStack,
|
||||
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||||
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
|
||||
|
|
@ -12,16 +11,19 @@ import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipal
|
|||
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
|
||||
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
|
||||
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
|
||||
import { MunicipalityListMobileSection } from '@/components/campaign/municipality/MunicipalityListMobileSection'
|
||||
import {
|
||||
SignalAgeReadout,
|
||||
TerritorialClassReadout,
|
||||
} from '@/components/campaign/municipality/MunicipalityListRowReadouts'
|
||||
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
|
||||
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
|
||||
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
|
||||
import { MunicipalitySortableHead } from '@/components/campaign/municipality/MunicipalitySortableHead'
|
||||
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
|
||||
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
|
||||
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
|
|
@ -41,9 +43,6 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro
|
|||
import {
|
||||
formatTerritorialClassWhy,
|
||||
municipalityColumnDescriptions,
|
||||
municipalityGeographyParts,
|
||||
territorialClassBadgeVariant,
|
||||
territorialClassLabels,
|
||||
type MunicipalityListColumnId,
|
||||
} from '@/utilities/municipality/municipalityLabels'
|
||||
import {
|
||||
|
|
@ -57,12 +56,6 @@ import {
|
|||
resolveMunicipalityListSort,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipality/municipalityListUrl'
|
||||
import {
|
||||
formatMunicipalitySignalAgeLabel,
|
||||
isMunicipalitySignalCold,
|
||||
MUNICIPALITY_COLD_SIGNAL_DAYS,
|
||||
municipalitySignalAgeInDays,
|
||||
} from '@/utilities/municipality/municipalitySignal'
|
||||
import type {
|
||||
EligibleAdvisorOption,
|
||||
MunicipalityAdvisorSummary,
|
||||
|
|
@ -102,87 +95,6 @@ export type MunicipalityListProps = {
|
|||
columnVisibility: CampaignColumnVisibility
|
||||
}
|
||||
|
||||
/**
|
||||
* E10 classe. Only the pill is visible — spelling the factors out under it
|
||||
* bloats the dense row on desktop and, on the mobile card, was the tallest
|
||||
* block in a card the field asked us to shorten (B42). So the "por quê" is
|
||||
* `sr-only` text here, and both surfaces add a tap/hover/focus channel around
|
||||
* it: the column's `cellTooltip` in the table, an explicit `CampaignHoverTooltip`
|
||||
* on the card. Neither may be dropped — the class must never reach anyone,
|
||||
* sighted or not, as a bare verdict.
|
||||
*/
|
||||
const TerritorialClassReadout = ({ municipality }: { municipality: MunicipalityListViewModel }) => {
|
||||
const why = formatTerritorialClassWhy(municipality.territorialClassFactors)
|
||||
|
||||
if (municipality.territorialClass === 'sem_base') {
|
||||
// Same idiom as the "2022" column: absent data is a dash, not a pill.
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
<span aria-hidden="true">—</span>
|
||||
<span className="sr-only">{why}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Badge variant={territorialClassBadgeVariant[municipality.territorialClass]}>
|
||||
{territorialClassLabels[municipality.territorialClass]}
|
||||
</Badge>
|
||||
<span className="sr-only">{why}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* E9 frescor: how long since anybody recorded anything here (staff update or
|
||||
* leadership pledge).
|
||||
*
|
||||
* Cold reads in the warning amber, NOT destructive: early in the campaign
|
||||
* most municípios are past the 21-day threshold, and painting them all red
|
||||
* would drown the one state that really is an error in this list — a priority
|
||||
* município with nobody answering for it. Same reason the priority flag stays
|
||||
* an icon instead of a third badge: the row already carries the priority signal and,
|
||||
* when it applies, the missing-advisor one.
|
||||
*/
|
||||
const SignalAgeReadout = ({
|
||||
lastSignalAt,
|
||||
layout,
|
||||
}: {
|
||||
lastSignalAt: string | null
|
||||
layout: 'table' | 'card'
|
||||
}) => {
|
||||
const ageInDays = municipalitySignalAgeInDays(lastSignalAt)
|
||||
const isCold = isMunicipalitySignalCold(ageInDays)
|
||||
const ageLabel = formatMunicipalitySignalAgeLabel(ageInDays)
|
||||
const dateLabel = lastSignalAt ? dateFormatter.format(new Date(lastSignalAt)) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-signal={isCold ? 'cold' : 'fresh'}
|
||||
className={cn('flex flex-col gap-0.5', layout === 'card' && 'text-sm')}
|
||||
title={
|
||||
isCold
|
||||
? `Sem registro novo há ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias ou mais — atualização da equipe ou declaração de liderança.`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 tabular-nums',
|
||||
isCold ? 'font-medium text-estimate-pending-foreground' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{isCold ? <CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" /> : null}
|
||||
{ageLabel}
|
||||
</span>
|
||||
{dateLabel ? (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{dateLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Replaces only the rows: the filter header row and the overview stay put. */
|
||||
const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState }) => (
|
||||
<Empty className="min-h-56">
|
||||
|
|
@ -214,11 +126,6 @@ const advisorEntries = (
|
|||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
|
||||
) => advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById)
|
||||
|
||||
const advisorNames = (
|
||||
municipality: MunicipalityListViewModel,
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
|
||||
): string[] => advisorEntries(municipality, advisorNamesById).map((advisor) => advisor.name)
|
||||
|
||||
/**
|
||||
* Desktop table columns (Pass 2 W1). `id` is the picker key, cookie key, and B22
|
||||
* description key — typed instead of widening to `string`.
|
||||
|
|
@ -524,172 +431,16 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
{sortSummary}
|
||||
</p>
|
||||
|
||||
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
|
||||
{municipalities.length === 0 ? <MunicipalityListEmptyState state={state} /> : null}
|
||||
{municipalities.map((municipality) => {
|
||||
const names = advisorNames(municipality, advisorNamesById)
|
||||
const position = municipality.votePosition2022
|
||||
const isPriority = municipality.priority === 'alta'
|
||||
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
|
||||
return (
|
||||
<article
|
||||
key={municipality.id}
|
||||
className="relative flex flex-col gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
{/*
|
||||
* The name's link stretches over the whole card, so tapping
|
||||
* anywhere that isn't a control opens the município — B42
|
||||
* removed the "Abrir município" button that used to be the
|
||||
* only way in. Each control carries its own `relative`,
|
||||
* which paints it above this overlay and keeps its own tap;
|
||||
* nothing else in the card may be positioned, or it would
|
||||
* become a hole in the card's tap target.
|
||||
*/}
|
||||
<h3 className="font-medium">
|
||||
<Link
|
||||
href={`/campanha/municipios/${municipality.slug}`}
|
||||
className="rounded-md after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{municipality.name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="relative">
|
||||
<TerritoryLink region={region} />
|
||||
</span>
|
||||
{zoneSuffix ? ` ${zoneSuffix}` : null}
|
||||
</p>
|
||||
{position ? (
|
||||
<MunicipalityVotePositionReadout position={position} layout="card" />
|
||||
) : null}
|
||||
</div>
|
||||
{isPriority && isStaffView ? (
|
||||
<MunicipalityPriorityIndicator className="relative size-11" />
|
||||
) : null}
|
||||
</div>
|
||||
{isStaffView ? (
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Classe</dt>
|
||||
<dd className="flex flex-wrap items-center gap-2">
|
||||
{/*
|
||||
* The card has no hover, so the "por quê" the class must
|
||||
* never travel without gets the same tap-to-open channel
|
||||
* the desktop column gets through `cellTooltip` — the
|
||||
* `sr-only` copy inside the readout covers AT either way.
|
||||
*/}
|
||||
<CampaignHoverTooltip
|
||||
content={
|
||||
municipality.territorialClass === 'sem_base'
|
||||
? null
|
||||
: formatTerritorialClassWhy(municipality.territorialClassFactors)
|
||||
}
|
||||
align="start"
|
||||
>
|
||||
<span className="relative inline-flex items-center">
|
||||
<TerritorialClassReadout municipality={municipality} />
|
||||
</span>
|
||||
</CampaignHoverTooltip>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{municipalityColumnLabels.goalCoverage}
|
||||
</dt>
|
||||
<dd>
|
||||
<MunicipalityListGoalCoverageCell
|
||||
coverageByScenario={municipality.goalCoverageByScenario}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Votos estimados</dt>
|
||||
<dd>
|
||||
<MunicipalityListExpectedVotesControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
expectedVotes={municipality.expectedVotes}
|
||||
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
|
||||
variant="sheet"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">Nível</dt>
|
||||
<dd>
|
||||
{props.canMoveEngagementLevel ? (
|
||||
<MunicipalityListLevelControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
level={municipality.engagementLevel}
|
||||
levelNote={municipality.levelNote}
|
||||
levelChangedAt={municipality.levelChangedAt}
|
||||
variant="sheet"
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityLevelBadge
|
||||
level={municipality.engagementLevel}
|
||||
note={municipality.levelNote}
|
||||
layout="card"
|
||||
/>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Tendência</dt>
|
||||
<dd>
|
||||
<MunicipalityListTrendControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
status={municipality.politicalTrendStatus}
|
||||
trendNote={municipality.politicalTrendNote}
|
||||
variant="sheet"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Último sinal</dt>
|
||||
<dd>
|
||||
<MunicipalityListSignalControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
municipalityName={municipality.name}
|
||||
lastSignalAt={municipality.lastSignalAt}
|
||||
variant="sheet"
|
||||
formAction={props.signalFormAction}
|
||||
>
|
||||
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
|
||||
</MunicipalityListSignalControl>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Assessores</dt>
|
||||
<dd>
|
||||
{isCoordinator ? (
|
||||
<MunicipalityListAdvisorsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={isPriority}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
variant="sheet"
|
||||
/>
|
||||
) : names.length ? (
|
||||
names.join(', ')
|
||||
) : (
|
||||
<MissingAdvisorBadge isPriority={isPriority} />
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<MunicipalityListMobileSection
|
||||
municipalities={municipalities}
|
||||
advisorNamesById={advisorNamesById}
|
||||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
canMoveEngagementLevel={props.canMoveEngagementLevel}
|
||||
advisorOptions={advisorOptions}
|
||||
signalFormAction={props.signalFormAction}
|
||||
emptySlot={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
|
||||
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
|
||||
<CampaignTable
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useC
|
|||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { DrawerClose } from '@/components/ui/Drawer'
|
||||
import { DrawerCloseButton } from '@/components/ui/Drawer'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
|
|
@ -236,14 +236,10 @@ export const MunicipalityListLevelControl = ({
|
|||
triggerBusy={isPending}
|
||||
statusMessage={isPending ? 'Registrando nível.' : (errorMessage ?? '')}
|
||||
footer={
|
||||
isSheet ? (
|
||||
isSheet && open ? (
|
||||
<>
|
||||
{submitButton}
|
||||
<DrawerClose
|
||||
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
|
||||
>
|
||||
Cancelar
|
||||
</DrawerClose>
|
||||
<DrawerCloseButton>Cancelar</DrawerCloseButton>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
advisorEntriesFromIds,
|
||||
MissingAdvisorBadge,
|
||||
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||||
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
|
||||
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
|
||||
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
|
||||
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
|
||||
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
|
||||
import {
|
||||
SignalAgeReadout,
|
||||
TerritorialClassCardReadout,
|
||||
} from '@/components/campaign/municipality/MunicipalityListRowReadouts'
|
||||
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
|
||||
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
|
||||
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
|
||||
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
|
||||
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { municipalityGeographyParts } from '@/utilities/municipality/municipalityLabels'
|
||||
import { municipalityColumnLabels } from '@/utilities/municipality/municipalityListUrl'
|
||||
import type {
|
||||
EligibleAdvisorOption,
|
||||
MunicipalityAdvisorSummary,
|
||||
MunicipalityListViewModel,
|
||||
} from '@/utilities/municipality/municipalityViewModels'
|
||||
import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
|
||||
|
||||
type MunicipalityStaffFormAction = (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
|
||||
export type MunicipalityListMobileCardsProps = {
|
||||
municipalities: MunicipalityListViewModel[]
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
|
||||
isStaffView: boolean
|
||||
isCoordinator: boolean
|
||||
canMoveEngagementLevel: boolean
|
||||
advisorOptions: EligibleAdvisorOption[]
|
||||
signalFormAction: MunicipalityStaffFormAction
|
||||
emptySlot: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* B42 mobile cards with sheet-variant quick edits. Wrapped by
|
||||
* `MunicipalityListMobileSection`, which hosts a single shared Drawer.
|
||||
*/
|
||||
export const MunicipalityListMobileCards = ({
|
||||
municipalities,
|
||||
advisorNamesById,
|
||||
isStaffView,
|
||||
isCoordinator,
|
||||
canMoveEngagementLevel,
|
||||
advisorOptions,
|
||||
signalFormAction,
|
||||
emptySlot,
|
||||
}: MunicipalityListMobileCardsProps) => (
|
||||
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
|
||||
{municipalities.length === 0 ? emptySlot : null}
|
||||
{municipalities.map((municipality) => {
|
||||
const names = advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById).map(
|
||||
(advisor) => advisor.name,
|
||||
)
|
||||
const position = municipality.votePosition2022
|
||||
const isPriority = municipality.priority === 'alta'
|
||||
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
|
||||
return (
|
||||
<article
|
||||
key={municipality.id}
|
||||
className="relative flex flex-col gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-medium">
|
||||
<Link
|
||||
href={`/campanha/municipios/${municipality.slug}`}
|
||||
className="rounded-md after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{municipality.name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="relative">
|
||||
<TerritoryLink region={region} />
|
||||
</span>
|
||||
{zoneSuffix ? ` ${zoneSuffix}` : null}
|
||||
</p>
|
||||
{position ? (
|
||||
<MunicipalityVotePositionReadout position={position} layout="card" />
|
||||
) : null}
|
||||
</div>
|
||||
{isPriority && isStaffView ? (
|
||||
<MunicipalityPriorityIndicator className="relative size-11" />
|
||||
) : null}
|
||||
</div>
|
||||
{isStaffView ? (
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Classe</dt>
|
||||
<dd className="flex flex-wrap items-center gap-2">
|
||||
<TerritorialClassCardReadout municipality={municipality} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{municipalityColumnLabels.goalCoverage}</dt>
|
||||
<dd>
|
||||
<MunicipalityListGoalCoverageCell
|
||||
coverageByScenario={municipality.goalCoverageByScenario}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Votos estimados</dt>
|
||||
<dd>
|
||||
<MunicipalityListExpectedVotesControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
expectedVotes={municipality.expectedVotes}
|
||||
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
|
||||
variant="sheet"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">Nível</dt>
|
||||
<dd>
|
||||
{canMoveEngagementLevel ? (
|
||||
<MunicipalityListLevelControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
level={municipality.engagementLevel}
|
||||
levelNote={municipality.levelNote}
|
||||
levelChangedAt={municipality.levelChangedAt}
|
||||
variant="sheet"
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityLevelBadge
|
||||
level={municipality.engagementLevel}
|
||||
note={municipality.levelNote}
|
||||
layout="card"
|
||||
/>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Tendência</dt>
|
||||
<dd>
|
||||
<MunicipalityListTrendControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
status={municipality.politicalTrendStatus}
|
||||
trendNote={municipality.politicalTrendNote}
|
||||
variant="sheet"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Último sinal</dt>
|
||||
<dd>
|
||||
<MunicipalityListSignalControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
municipalityName={municipality.name}
|
||||
lastSignalAt={municipality.lastSignalAt}
|
||||
variant="sheet"
|
||||
formAction={signalFormAction}
|
||||
>
|
||||
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
|
||||
</MunicipalityListSignalControl>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Assessores</dt>
|
||||
<dd>
|
||||
{isCoordinator ? (
|
||||
<MunicipalityListAdvisorsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={isPriority}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
variant="sheet"
|
||||
/>
|
||||
) : names.length ? (
|
||||
names.join(', ')
|
||||
) : (
|
||||
<MissingAdvisorBadge isPriority={isPriority} />
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
'use client'
|
||||
|
||||
import type { MunicipalityListMobileCardsProps } from '@/components/campaign/municipality/MunicipalityListMobileCards'
|
||||
import { MunicipalityListMobileCards } from '@/components/campaign/municipality/MunicipalityListMobileCards'
|
||||
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
|
||||
|
||||
type MunicipalityListMobileSectionProps = MunicipalityListMobileCardsProps
|
||||
|
||||
/** Mobile cards (`md:hidden`) with one shared bottom sheet for all quick edits. */
|
||||
export const MunicipalityListMobileSection = (props: MunicipalityListMobileSectionProps) => (
|
||||
<CampaignListSheetProvider>
|
||||
<MunicipalityListMobileCards {...props} />
|
||||
</CampaignListSheetProvider>
|
||||
)
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
'use client'
|
||||
|
||||
import { CircleAlertIcon } from 'lucide-react'
|
||||
|
||||
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
formatTerritorialClassWhy,
|
||||
territorialClassBadgeVariant,
|
||||
territorialClassLabels,
|
||||
} from '@/utilities/municipality/municipalityLabels'
|
||||
import {
|
||||
formatMunicipalitySignalAgeLabel,
|
||||
isMunicipalitySignalCold,
|
||||
MUNICIPALITY_COLD_SIGNAL_DAYS,
|
||||
municipalitySignalAgeInDays,
|
||||
} from '@/utilities/municipality/municipalitySignal'
|
||||
import type { MunicipalityListViewModel } from '@/utilities/municipality/municipalityViewModels'
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
|
||||
|
||||
/** E10 classe readout — shared by the table column and the mobile card (B42). */
|
||||
export const TerritorialClassReadout = ({
|
||||
municipality,
|
||||
}: {
|
||||
municipality: MunicipalityListViewModel
|
||||
}) => {
|
||||
const why = formatTerritorialClassWhy(municipality.territorialClassFactors)
|
||||
|
||||
if (municipality.territorialClass === 'sem_base') {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
<span aria-hidden="true">—</span>
|
||||
<span className="sr-only">{why}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Badge variant={territorialClassBadgeVariant[municipality.territorialClass]}>
|
||||
{territorialClassLabels[municipality.territorialClass]}
|
||||
</Badge>
|
||||
<span className="sr-only">{why}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** E9 frescor readout — shared by the table column and the mobile card. */
|
||||
export const SignalAgeReadout = ({
|
||||
lastSignalAt,
|
||||
layout,
|
||||
}: {
|
||||
lastSignalAt: string | null
|
||||
layout: 'table' | 'card'
|
||||
}) => {
|
||||
const ageInDays = municipalitySignalAgeInDays(lastSignalAt)
|
||||
const isCold = isMunicipalitySignalCold(ageInDays)
|
||||
const ageLabel = formatMunicipalitySignalAgeLabel(ageInDays)
|
||||
const dateLabel = lastSignalAt ? dateFormatter.format(new Date(lastSignalAt)) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-signal={isCold ? 'cold' : 'fresh'}
|
||||
className={cn('flex flex-col gap-0.5', layout === 'card' && 'text-sm')}
|
||||
title={
|
||||
isCold
|
||||
? `Sem registro novo há ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias ou mais — atualização da equipe ou declaração de liderança.`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 tabular-nums',
|
||||
isCold ? 'font-medium text-estimate-pending-foreground' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{isCold ? <CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" /> : null}
|
||||
{ageLabel}
|
||||
</span>
|
||||
{dateLabel ? (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{dateLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TerritorialClassCardReadout = ({
|
||||
municipality,
|
||||
}: {
|
||||
municipality: MunicipalityListViewModel
|
||||
}) => (
|
||||
<CampaignHoverTooltip
|
||||
content={
|
||||
municipality.territorialClass === 'sem_base'
|
||||
? null
|
||||
: formatTerritorialClassWhy(municipality.territorialClassFactors)
|
||||
}
|
||||
align="start"
|
||||
>
|
||||
<span className="relative inline-flex items-center">
|
||||
<TerritorialClassReadout municipality={municipality} />
|
||||
</span>
|
||||
</CampaignHoverTooltip>
|
||||
)
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DrawerClose } from '@/components/ui/Drawer'
|
||||
import { DrawerCloseButton } from '@/components/ui/Drawer'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import {
|
||||
|
|
@ -82,14 +82,10 @@ export const MunicipalityListSignalControl = ({
|
|||
contentClassName="w-80"
|
||||
trigger={children}
|
||||
footer={
|
||||
isSheet ? (
|
||||
isSheet && open ? (
|
||||
<>
|
||||
{submitButton}
|
||||
<DrawerClose
|
||||
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
|
||||
>
|
||||
Cancelar
|
||||
</DrawerClose>
|
||||
<DrawerCloseButton>Cancelar</DrawerCloseButton>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizard
|
|||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
|
|
@ -104,9 +104,7 @@ export const WizardSignalTypeStep = ({
|
|||
<p className="text-sm text-muted-foreground">{infoEntry.infoContent}</p>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose render={<Button variant="outline" className="min-h-11 w-full" />}>
|
||||
Fechar
|
||||
</DrawerClose>
|
||||
<DrawerCloseButton>Fechar</DrawerCloseButton>
|
||||
</DrawerFooter>
|
||||
</>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
'use client'
|
||||
|
||||
import { useRef, useState, type ReactNode } from 'react'
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useCampaignListSheet } from '@/components/campaign/shared/CampaignListSheetHost'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
|
|
@ -82,6 +83,10 @@ type CampaignCellEditOverlayProps = {
|
|||
* Only the container lives here. Each control keeps its own auto-save scaffold
|
||||
* (debounce, abort, flush on close), which is what `onOpenChange` carries — so
|
||||
* closing the Drawer commits exactly like dismissing the Popover does.
|
||||
*
|
||||
* When a `CampaignListSheetProvider` wraps the list (B42 mobile cards), sheet
|
||||
* cells publish into that single Drawer instead of mounting one per row — prod
|
||||
* was tripping React #130 with ~125 idle drawer roots on a 25-row page.
|
||||
*/
|
||||
export const CampaignCellEditOverlay = ({
|
||||
variant,
|
||||
|
|
@ -102,6 +107,9 @@ export const CampaignCellEditOverlay = ({
|
|||
preventPopoverAutoFocus,
|
||||
children,
|
||||
}: CampaignCellEditOverlayProps) => {
|
||||
const sharedSheet = useCampaignListSheet()
|
||||
const wasOpenRef = useRef(open)
|
||||
|
||||
/**
|
||||
* The region is mounted from the first open onwards and never unmounted
|
||||
* again — a live region has to exist BEFORE its text changes or most screen
|
||||
|
|
@ -152,50 +160,73 @@ export const CampaignCellEditOverlay = ({
|
|||
</p>
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (variant !== 'sheet' || !sharedSheet) return
|
||||
|
||||
const wasOpen = wasOpenRef.current
|
||||
wasOpenRef.current = open
|
||||
|
||||
if (!open) {
|
||||
if (wasOpen) sharedSheet.dismissSheet(onOpenChange)
|
||||
return
|
||||
}
|
||||
|
||||
sharedSheet.openSheet({
|
||||
title,
|
||||
description,
|
||||
hasCustomFooter: Boolean(footer),
|
||||
sheetBodyClassName,
|
||||
onOpenChange,
|
||||
})
|
||||
}, [variant, sharedSheet, open, title, description, footer, sheetBodyClassName, onOpenChange])
|
||||
|
||||
if (variant === 'sheet' && sharedSheet) {
|
||||
const showPortals = open && sharedSheet.isActiveSheet(onOpenChange)
|
||||
const bodyTarget = showPortals ? sharedSheet.bodyPortalRef.current : null
|
||||
const footerTarget = showPortals ? sharedSheet.footerPortalRef.current : null
|
||||
// portalRevision keeps this render in sync after the drawer mounts its targets.
|
||||
void sharedSheet.portalRevision
|
||||
|
||||
return (
|
||||
<>
|
||||
{triggerButton}
|
||||
{liveRegion}
|
||||
{showPortals && bodyTarget ? createPortal(children, bodyTarget) : null}
|
||||
{showPortals && footerTarget && footer ? createPortal(footer, footerTarget) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'sheet') {
|
||||
return (
|
||||
<>
|
||||
{triggerButton}
|
||||
{liveRegion}
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
{/*
|
||||
* Focus lands on the title, never on the first field: base-ui's
|
||||
* default (first tabbable element) raises the virtual keyboard over
|
||||
* the sheet before it can be read, and every one of these sheets is
|
||||
* opened to look at a value at least as often as to type one. The
|
||||
* title is focusable only programmatically (`tabIndex={-1}`), so it
|
||||
* adds no stop to the tab order.
|
||||
*/}
|
||||
<DrawerContent initialFocus={titleRef}>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle ref={titleRef} tabIndex={-1} className="outline-none">
|
||||
{title}
|
||||
</DrawerTitle>
|
||||
{/* Guarded, not required: every sheet today names its row, but one
|
||||
opened from under a heading that already does would pass none. */}
|
||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||
</DrawerHeader>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-3 pb-2',
|
||||
sheetBodyClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
{footer ?? (
|
||||
// Swiping down is the gesture, but it is not an affordance
|
||||
// anyone sees.
|
||||
<DrawerClose
|
||||
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
|
||||
{open || everOpened ? (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
{open ? (
|
||||
<DrawerContent initialFocus={titleRef}>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle ref={titleRef} tabIndex={-1} className="outline-none">
|
||||
{title}
|
||||
</DrawerTitle>
|
||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||
</DrawerHeader>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-3 pb-2',
|
||||
sheetBodyClassName,
|
||||
)}
|
||||
>
|
||||
Fechar
|
||||
</DrawerClose>
|
||||
)}
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{children}
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
{footer ?? <DrawerCloseButton>Fechar</DrawerCloseButton>}
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
) : null}
|
||||
</Drawer>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
163
src/components/campaign/shared/CampaignListSheetHost.tsx
Normal file
163
src/components/campaign/shared/CampaignListSheetHost.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from 'react'
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/Drawer'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type CampaignListSheetChrome = {
|
||||
title: string
|
||||
description?: string
|
||||
hasCustomFooter: boolean
|
||||
sheetBodyClassName?: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
type CampaignListSheetContextValue = {
|
||||
bodyPortalRef: RefObject<HTMLDivElement | null>
|
||||
footerPortalRef: RefObject<HTMLDivElement | null>
|
||||
/** Bumps when the drawer body/footer mount targets attach — portals read this. */
|
||||
portalRevision: number
|
||||
openSheet: (chrome: CampaignListSheetChrome) => void
|
||||
dismissSheet: (onOpenChange: (open: boolean) => void) => void
|
||||
isActiveSheet: (onOpenChange: (open: boolean) => void) => boolean
|
||||
}
|
||||
|
||||
const CampaignListSheetContext = createContext<CampaignListSheetContextValue | null>(null)
|
||||
|
||||
export const useCampaignListSheet = () => useContext(CampaignListSheetContext)
|
||||
|
||||
/**
|
||||
* One bottom Drawer for an entire list surface (B42 mobile cards). Cell bodies
|
||||
* portal into it so we never mount hundreds of Drawer roots and never sync form
|
||||
* trees through provider state (which tripped React #185 in prod).
|
||||
*/
|
||||
export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) => {
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
||||
const bodyPortalRef = useRef<HTMLDivElement | null>(null)
|
||||
const footerPortalRef = useRef<HTMLDivElement | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [chrome, setChrome] = useState<CampaignListSheetChrome | null>(null)
|
||||
const [portalRevision, setPortalRevision] = useState(0)
|
||||
const activeOnOpenChangeRef = useRef<((open: boolean) => void) | null>(null)
|
||||
|
||||
const attachBodyPortal = useCallback((node: HTMLDivElement | null) => {
|
||||
bodyPortalRef.current = node
|
||||
if (node) setPortalRevision((value) => value + 1)
|
||||
}, [])
|
||||
|
||||
const attachFooterPortal = useCallback((node: HTMLDivElement | null) => {
|
||||
footerPortalRef.current = node
|
||||
if (node) setPortalRevision((value) => value + 1)
|
||||
}, [])
|
||||
|
||||
const dismissSheet = useCallback((onOpenChange: (open: boolean) => void) => {
|
||||
if (activeOnOpenChangeRef.current !== onOpenChange) return
|
||||
setOpen(false)
|
||||
activeOnOpenChangeRef.current = null
|
||||
setChrome(null)
|
||||
onOpenChange(false)
|
||||
}, [])
|
||||
|
||||
const openSheet = useCallback((next: CampaignListSheetChrome) => {
|
||||
const previousOnOpenChange = activeOnOpenChangeRef.current
|
||||
if (previousOnOpenChange && previousOnOpenChange !== next.onOpenChange) {
|
||||
previousOnOpenChange(false)
|
||||
}
|
||||
activeOnOpenChangeRef.current = next.onOpenChange
|
||||
setChrome((current) => {
|
||||
if (
|
||||
current &&
|
||||
current.onOpenChange === next.onOpenChange &&
|
||||
current.title === next.title &&
|
||||
current.description === next.description &&
|
||||
current.sheetBodyClassName === next.sheetBodyClassName &&
|
||||
current.hasCustomFooter === next.hasCustomFooter
|
||||
) {
|
||||
return current
|
||||
}
|
||||
return next
|
||||
})
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
const isActiveSheet = useCallback(
|
||||
(onOpenChange: (open: boolean) => void) =>
|
||||
open && activeOnOpenChangeRef.current === onOpenChange,
|
||||
[open],
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (nextOpen) return
|
||||
const onOpenChange = activeOnOpenChangeRef.current
|
||||
if (!onOpenChange) {
|
||||
setOpen(false)
|
||||
setChrome(null)
|
||||
return
|
||||
}
|
||||
dismissSheet(onOpenChange)
|
||||
},
|
||||
[dismissSheet],
|
||||
)
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
bodyPortalRef,
|
||||
footerPortalRef,
|
||||
portalRevision,
|
||||
openSheet,
|
||||
dismissSheet,
|
||||
isActiveSheet,
|
||||
}),
|
||||
[portalRevision, openSheet, dismissSheet, isActiveSheet],
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignListSheetContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
{chrome && open ? (
|
||||
<DrawerContent initialFocus={titleRef}>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle ref={titleRef} tabIndex={-1} className="outline-none">
|
||||
{chrome.title}
|
||||
</DrawerTitle>
|
||||
{chrome.description ? (
|
||||
<DrawerDescription>{chrome.description}</DrawerDescription>
|
||||
) : null}
|
||||
</DrawerHeader>
|
||||
<div
|
||||
ref={attachBodyPortal}
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-3 pb-2',
|
||||
chrome.sheetBodyClassName,
|
||||
)}
|
||||
/>
|
||||
<DrawerFooter>
|
||||
<div ref={attachFooterPortal} className="flex w-full flex-col gap-2" />
|
||||
{!chrome.hasCustomFooter ? <DrawerCloseButton>Fechar</DrawerCloseButton> : null}
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</CampaignListSheetContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,10 +3,9 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
|
|
@ -122,7 +121,7 @@ export const InstallPwaToast = () => {
|
|||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
return iosDrawerOpen ? (
|
||||
<Drawer open={iosDrawerOpen} onOpenChange={setIosDrawerOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
|
|
@ -137,20 +136,9 @@ export const InstallPwaToast = () => {
|
|||
<li>Confirme tocando em Adicionar.</li>
|
||||
</ol>
|
||||
<DrawerFooter>
|
||||
<DrawerClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={dismissInstallToast}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Entendi
|
||||
</DrawerClose>
|
||||
<DrawerCloseButton onClick={dismissInstallToast}>Entendi</DrawerCloseButton>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer'
|
||||
import { Drawer as DrawerPrimitive } from '@base-ui/react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DrawerContextProps = {
|
||||
|
|
@ -62,6 +63,32 @@ const DrawerClose = ({ ...props }: DrawerPrimitive.Close.Props) => (
|
|||
<DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
)
|
||||
|
||||
/** Styled close control — uses a render callback so prod bundles never hit base-ui's element `render` prop. */
|
||||
const DrawerCloseButton = ({
|
||||
className,
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) => (
|
||||
<DrawerClose
|
||||
render={(closeProps) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn('min-h-11 w-full', className)}
|
||||
{...props}
|
||||
{...closeProps}
|
||||
onClick={(event) => {
|
||||
closeProps.onClick?.(event)
|
||||
onClick?.(event)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</DrawerClose>
|
||||
)
|
||||
|
||||
const DrawerOverlay = ({ className, ...props }: DrawerPrimitive.Backdrop.Props) => (
|
||||
<DrawerPrimitive.Backdrop
|
||||
data-slot="drawer-overlay"
|
||||
|
|
@ -168,7 +195,7 @@ const DrawerDescription = ({ className, ...props }: DrawerPrimitive.Description.
|
|||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ export type CampaignWebAuthnRelyingParty = {
|
|||
|
||||
const RP_NAME = 'Campanha Jorge Solla'
|
||||
|
||||
/** Matches `LOCAL_AUTHORITY_PATTERN` in `campaignInviteOrigin` — kept local on purpose. */
|
||||
const LOCAL_REQUEST_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(?::[0-9]{1,5})?$/i
|
||||
|
||||
const isLocalRequestHost = (host: string | undefined): boolean =>
|
||||
Boolean(host && LOCAL_REQUEST_HOST.test(host))
|
||||
|
||||
/**
|
||||
* The authority the browser is actually talking to, which is what has to agree
|
||||
* with the relying party. Compared as `host` (name + port) and not as a full
|
||||
|
|
@ -61,9 +67,17 @@ const requestHostOf = ({
|
|||
export const getCampaignWebAuthnRelyingParty = (
|
||||
input: CampaignInviteOriginInput = {},
|
||||
): CampaignWebAuthnRelyingParty | null => {
|
||||
const requestHost = requestHostOf(input)
|
||||
// `getCampaignInviteBaseURL` reads `process.env.NODE_ENV`, which Next inlines
|
||||
// at build time as `"production"`. CI e2e serves that build on
|
||||
// http://localhost:3000 (`pnpm start`), which would otherwise demand HTTPS
|
||||
// DNS and hide biometrics. The request host is authoritative here.
|
||||
const environment =
|
||||
input.environment ?? (isLocalRequestHost(requestHost) ? ('test' as const) : undefined)
|
||||
|
||||
let baseURL: string
|
||||
try {
|
||||
baseURL = getCampaignInviteBaseURL(input)
|
||||
baseURL = getCampaignInviteBaseURL(environment ? { ...input, environment } : input)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
|
@ -74,7 +88,6 @@ export const getCampaignWebAuthnRelyingParty = (
|
|||
|
||||
// Absent host: a non-request context (a script, a test) that cannot be
|
||||
// checked. The ceremonies always pass the headers.
|
||||
const requestHost = requestHostOf(input)
|
||||
if (requestHost && requestHost !== url.host.toLowerCase()) return null
|
||||
|
||||
return { rpID, rpName: RP_NAME, origin: url.origin }
|
||||
|
|
|
|||
|
|
@ -43,15 +43,14 @@ test.describe('Início — busca global (B47)', () => {
|
|||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
const actionsSlot = page.locator('[data-slot="home-actions"]')
|
||||
await expect(actionsSlot).toBeVisible()
|
||||
const actionsChrome = page.locator('[data-slot="home-actions-chrome"]')
|
||||
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
|
||||
|
||||
await page.getByLabel('Buscar na campanha').focus()
|
||||
await expect(actionsSlot).toBeHidden({ timeout: 5000 })
|
||||
await expect(actionsChrome).toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
|
||||
|
||||
await page.getByLabel('Buscar na campanha').blur()
|
||||
await expect(actionsSlot).toBeVisible({ timeout: 5000 })
|
||||
await expect(actionsChrome).not.toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('staff typing in search keeps action strip hidden after debounce', async ({
|
||||
|
|
@ -64,14 +63,15 @@ test.describe('Início — busca global (B47)', () => {
|
|||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
const actionsSlot = page.locator('[data-slot="home-actions"]')
|
||||
await expect(actionsSlot).toBeVisible()
|
||||
const actionsChrome = page.locator('[data-slot="home-actions-chrome"]')
|
||||
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
|
||||
|
||||
await page.getByLabel('Buscar na campanha').fill('ca')
|
||||
await expect(actionsSlot).toBeHidden({ timeout: 5000 })
|
||||
await expect(actionsChrome).toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
|
||||
|
||||
await page.getByLabel('Buscar na campanha').fill('')
|
||||
await expect(actionsSlot).toBeVisible({ timeout: 5000 })
|
||||
await page.getByLabel('Buscar na campanha').blur()
|
||||
await expect(actionsChrome).not.toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('staff search shows municipality hits and opens detail (B48)', async ({
|
||||
|
|
@ -148,6 +148,8 @@ test.describe('Início — catálogo de ações (B45)', () => {
|
|||
})
|
||||
|
||||
test.describe('Wizard — busca município (B60)', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
test('selecting a municipality advances with ?municipio= and sticky caption', async ({
|
||||
campaign,
|
||||
page,
|
||||
|
|
@ -165,8 +167,8 @@ test.describe('Wizard — busca município (B60)', () => {
|
|||
await search.fill('Cairu')
|
||||
|
||||
const results = page.getByRole('region', { name: 'Resultados da busca' })
|
||||
await expect(results.getByRole('button', { name: /Cairu/i })).toBeVisible({ timeout: 15000 })
|
||||
await results.getByRole('button', { name: /Cairu/i }).click()
|
||||
await expect(results.getByRole('link', { name: /Cairu/i })).toBeVisible({ timeout: 15000 })
|
||||
await results.getByRole('link', { name: /Cairu/i }).click()
|
||||
|
||||
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\?municipio=cairu/)
|
||||
await expect(page.getByLabel(/Município em atualização: Cairu/i)).toBeVisible()
|
||||
|
|
@ -299,12 +301,7 @@ test.describe('Wizard — registrar sinal (B63)', () => {
|
|||
})
|
||||
await expect(page.getByRole('link', { name: 'Pular registro de sinal' })).toHaveCount(0)
|
||||
|
||||
await page
|
||||
.getByRole('listitem')
|
||||
.filter({ hasText: 'Invasão' })
|
||||
.getByRole('button')
|
||||
.first()
|
||||
.click()
|
||||
await page.getByRole('link', { name: /Invasão/i }).click()
|
||||
await page.waitForURL(/signalType=invasao/)
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Detalhar sinal: Invasão/i })).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -55,11 +55,14 @@ test.describe('Filtros salvos de Municípios', () => {
|
|||
await expect(shortcut).toHaveAttribute('aria-current', 'page')
|
||||
|
||||
// A page is a position inside the recorte, not part of it.
|
||||
await page.goto(`${SAVED_RECORTE}&page=2`)
|
||||
await page.goto(`${SAVED_RECORTE}&page=2`, NAVIGATION)
|
||||
await expect(shortcut).toHaveAttribute('aria-current', 'page')
|
||||
|
||||
// Another recorte is not this one, and the shortcut brings it back.
|
||||
await page.goto('/campanha/municipios')
|
||||
// Another recorte is not this one, and the shortcut brings it back. Use the
|
||||
// sidebar link — a bare `goto` here races an in-flight RSC navigation and
|
||||
// flakes as net::ERR_ABORTED under prod load (measured in CI 2026-07-30).
|
||||
await page.getByRole('link', { name: 'Municípios', exact: true }).click()
|
||||
await expect(page).toHaveURL('/campanha/municipios', NAVIGATION)
|
||||
await expect(shortcut).not.toHaveAttribute('aria-current', 'page')
|
||||
await shortcut.click()
|
||||
await expect(page).toHaveURL(SAVED_RECORTE, NAVIGATION)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,6 @@ test.describe('Territórios de Identidade', () => {
|
|||
await expect(page.getByRole('link', { name: 'Territórios', exact: true })).toHaveCount(0)
|
||||
|
||||
await page.goto('/campanha/territorios')
|
||||
await expect(page).toHaveURL(/\/campanha\/?$/)
|
||||
await expect(page).toHaveURL(/\/campanha\/contatos/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ test.describe('Wizard — header mobile (B75)', () => {
|
|||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu')
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({
|
||||
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ test('the dashboard map paints Salvador zone by zone and opens one', async ({ ca
|
|||
const password = coordinator.password
|
||||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await page.goto('/campanha/quadro')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Mapa dos Municípios' })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,15 @@ describe('getCampaignWebAuthnRelyingParty', () => {
|
|||
).toMatchObject({ rpID: 'localhost', origin: 'http://localhost:3100' })
|
||||
})
|
||||
|
||||
it('follows localhost when a production build is served there (CI e2e)', () => {
|
||||
expect(
|
||||
getCampaignWebAuthnRelyingParty({
|
||||
configuredURL: 'https://pt.jorgesolla.com.br',
|
||||
forwardedHost: 'localhost:3000',
|
||||
}),
|
||||
).toMatchObject({ rpID: 'localhost', origin: 'http://localhost:3000' })
|
||||
})
|
||||
|
||||
it('refuses production without a configured site URL', () => {
|
||||
expect(
|
||||
getCampaignWebAuthnRelyingParty({ environment: 'production', configuredURL: undefined }),
|
||||
|
|
|
|||
45
tests/unit/e2eAffectedManifest.unit.spec.ts
Normal file
45
tests/unit/e2eAffectedManifest.unit.spec.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { E2E_AFFECTED_MANIFEST } from '../../scripts/lib/e2e-affected-manifest.mjs'
|
||||
|
||||
const E2E_DIR = join(__dirname, '../e2e')
|
||||
|
||||
function specNamesOnDisk() {
|
||||
return readdirSync(E2E_DIR)
|
||||
.filter((file) => file.endsWith('.e2e.spec.ts'))
|
||||
.map((file) => file.slice(0, -'.e2e.spec.ts'.length))
|
||||
}
|
||||
|
||||
describe('E2E_AFFECTED_MANIFEST (OPS5)', () => {
|
||||
it('only references specs that exist on disk', () => {
|
||||
const onDisk = new Set(specNamesOnDisk())
|
||||
for (const entry of E2E_AFFECTED_MANIFEST) {
|
||||
for (const spec of entry.specs) {
|
||||
expect(onDisk.has(spec), `${spec} (prefixes: ${entry.prefixes.join(', ')})`).toBe(true)
|
||||
expect(existsSync(join(E2E_DIR, `${spec}.e2e.spec.ts`))).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps prefixes repo-relative and trailing-slash-clean', () => {
|
||||
for (const entry of E2E_AFFECTED_MANIFEST) {
|
||||
expect(entry.prefixes.length).toBeGreaterThan(0)
|
||||
for (const prefix of entry.prefixes) {
|
||||
expect(prefix.startsWith('src/')).toBe(true)
|
||||
expect(prefix.endsWith('/'), `${prefix} — drop the trailing slash`).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('covers every on-disk spec family that has a clear domain prefix', () => {
|
||||
// Smoke/infrastructure specs are allowed to be unmapped (they run in full
|
||||
// mode); this pins the reverse direction: no manifest entry is dead.
|
||||
const mapped = new Set(E2E_AFFECTED_MANIFEST.flatMap((entry) => entry.specs))
|
||||
expect(mapped.size).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
})
|
||||
87
tests/unit/testAffected.unit.spec.ts
Normal file
87
tests/unit/testAffected.unit.spec.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
classifyTestScope,
|
||||
HIGH_RISK_EXACT,
|
||||
HIGH_RISK_PREFIXES,
|
||||
selectE2eSpecs,
|
||||
} from '../../scripts/lib/test-affected-core.mjs'
|
||||
|
||||
const changed = (path: string) => ({ path, status: 'M' })
|
||||
|
||||
describe('classifyTestScope (OPS5)', () => {
|
||||
it('runs the full suite when a high-risk path changes', () => {
|
||||
for (const path of [
|
||||
'src/migrations/20260730_000000_x.ts',
|
||||
'src/payload-types.ts',
|
||||
'pnpm-lock.yaml',
|
||||
'src/collections/Post.ts',
|
||||
'scripts/lib/seed-minimal-manifest.mjs',
|
||||
'tests/helpers/campaignFixtures.ts',
|
||||
]) {
|
||||
expect(classifyTestScope([changed(path)]).mode, path).toBe('full')
|
||||
}
|
||||
})
|
||||
|
||||
it('runs the full suite when a new src file appears', () => {
|
||||
expect(classifyTestScope([{ path: 'src/utilities/newThing.ts', status: 'A' }]).mode).toBe(
|
||||
'full',
|
||||
)
|
||||
})
|
||||
|
||||
it('selects vitest --changed for a plain src/tests diff', () => {
|
||||
expect(classifyTestScope([changed('src/utilities/posts.ts')]).mode).toBe('changed')
|
||||
expect(classifyTestScope([changed('tests/int/api.int.spec.ts')]).mode).toBe('changed')
|
||||
})
|
||||
|
||||
it('skips tests for a docs-only diff', () => {
|
||||
expect(classifyTestScope([changed('docs/AGENT-OPS.md'), changed('README.md')]).mode).toBe(
|
||||
'none',
|
||||
)
|
||||
})
|
||||
|
||||
it('high-risk lists cover the harness files the CI relies on', () => {
|
||||
expect(HIGH_RISK_EXACT.has('vitest.config.mts')).toBe(true)
|
||||
expect(HIGH_RISK_EXACT.has('playwright.config.ts')).toBe(true)
|
||||
expect(HIGH_RISK_PREFIXES).toContain('tests/e2e/fixtures/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectE2eSpecs (OPS5)', () => {
|
||||
const manifest = [
|
||||
{ prefixes: ['src/components/campaign/municipality'], specs: ['campaignMunicipalities'] },
|
||||
{ prefixes: ['src/app/(payload)'], specs: ['admin'] },
|
||||
]
|
||||
|
||||
it('maps src prefixes to specs via the manifest', () => {
|
||||
const result = selectE2eSpecs(
|
||||
[changed('src/components/campaign/municipality/MunicipalityTable.tsx')],
|
||||
manifest,
|
||||
)
|
||||
expect(result).toMatchObject({ mode: 'selected', specs: ['campaignMunicipalities'] })
|
||||
})
|
||||
|
||||
it('runs a changed e2e spec directly', () => {
|
||||
const result = selectE2eSpecs([changed('tests/e2e/campaignAuth.e2e.spec.ts')], manifest)
|
||||
expect(result.mode).toBe('selected')
|
||||
expect(result.specs).toContain('campaignAuth')
|
||||
})
|
||||
|
||||
it('runs the full suite on high-risk paths', () => {
|
||||
expect(selectE2eSpecs([changed('tests/e2e/fixtures/auth.ts')], manifest).mode).toBe('full')
|
||||
expect(selectE2eSpecs([changed('src/migrations/x.ts')], manifest).mode).toBe('full')
|
||||
})
|
||||
|
||||
it('reports unmapped src paths without failing and skips the run', () => {
|
||||
const result = selectE2eSpecs([changed('src/utilities/brandNewModule.ts')], manifest)
|
||||
expect(result.mode).toBe('none')
|
||||
expect(result.unmapped).toEqual(['src/utilities/brandNewModule.ts'])
|
||||
})
|
||||
|
||||
it('skips e2e for diffs with no src/e2e changes', () => {
|
||||
const result = selectE2eSpecs([changed('docs/AGENT-OPS.md')], manifest)
|
||||
expect(result).toMatchObject({ mode: 'none', specs: [], unmapped: [] })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue